diff --git a/.env.example b/.env.example index e1fdfeb1e..90d211be5 100644 --- a/.env.example +++ b/.env.example @@ -18,6 +18,13 @@ GUILD_ID=your_discord_guild_id # Discord OAuth2 client secret (required for web dashboard) DISCORD_CLIENT_SECRET=your_discord_client_secret +# Discord OAuth2 redirect URI (required for web dashboard) +DISCORD_REDIRECT_URI=http://localhost:3001/api/v1/auth/discord/callback + +# Session secret for JWT signing (required for OAuth2 dashboard auth) +# Generate with: openssl rand -base64 32 +SESSION_SECRET=your_session_secret + # ── OpenClaw ───────────────────────────────── # OpenClaw chat completions endpoint (required) @@ -61,6 +68,15 @@ DASHBOARD_URL=http://localhost:3000 # Discord client ID exposed to browser (required for web dashboard) NEXT_PUBLIC_DISCORD_CLIENT_ID=your_discord_client_id +# ── Bot Owner / Permissions ─────────────────── + +# Bot owner Discord user IDs are configured in config.json under +# permissions.botOwners. The default value is the upstream maintainer's ID. +# **Forks/deployers:** Update config.json permissions.botOwners with your own +# Discord user ID(s). Bot owners bypass all permission checks. +# Find your Discord user ID: User Settings → Advanced → Enable Developer Mode, +# then right-click your name → Copy User ID. + # ── Optional Integrations ──────────────────── # mem0 API key for user long-term memory (optional — memory features disabled without it) diff --git a/README.md b/README.md index a50f78218..7a4a3b96c 100644 --- a/README.md +++ b/README.md @@ -187,11 +187,15 @@ All configuration lives in `config.json` and can be updated at runtime via the ` |-----|------|-------------| | `enabled` | boolean | Enable permission checks | | `adminRoleId` | string | Role ID for admin commands | -| `allowedCommands` | object | Per-command permission levels | +| `moderatorRoleId` | string | Role ID for moderator commands | +| `botOwners` | string[] | Discord user IDs that bypass all permission checks | +| `allowedCommands` | object | Per-command permission levels (`everyone`, `moderator`, `admin`) | + +> **⚠️ For forks/deployers:** The default `config.json` ships with the upstream maintainer's Discord user ID in `permissions.botOwners`. Update this array with your own Discord user ID(s) before deploying. Bot owners bypass all permission checks. ## ⚔️ Moderation Commands -All moderation commands require the admin role (configured via `permissions.adminRoleId`). +Most moderation commands require admin-level access. `/modlog` is moderator-level by default (`permissions.allowedCommands.modlog = "moderator"`). ### Core Actions @@ -351,6 +355,9 @@ Set these in the Railway dashboard for the Bot service: | `DATABASE_URL` | Yes | `${{Postgres.DATABASE_URL}}` — Railway variable reference | | `MEM0_API_KEY` | No | Mem0 API key for long-term memory | | `LOG_LEVEL` | No | `debug`, `info`, `warn`, or `error` (default: `info`) | +| `SESSION_SECRET` | Yes | JWT signing secret for OAuth2 sessions. Generate with `openssl rand -base64 32` | +| `DISCORD_CLIENT_SECRET` | Yes | Discord OAuth2 client secret (required for dashboard auth) | +| `DISCORD_REDIRECT_URI` | Yes | OAuth2 callback URL (e.g. `https://your-bot/api/v1/auth/discord/callback`) | | `BOT_API_SECRET` | Yes | Shared secret for web dashboard API auth | ### Web Dashboard Environment Variables diff --git a/config.json b/config.json index 782062fad..88939c875 100644 --- a/config.json +++ b/config.json @@ -82,6 +82,8 @@ "permissions": { "enabled": true, "adminRoleId": null, + "moderatorRoleId": null, + "botOwners": ["191633014441115648"], "usePermissions": true, "allowedCommands": { "ping": "everyone", @@ -101,7 +103,7 @@ "lock": "admin", "unlock": "admin", "slowmode": "admin", - "modlog": "admin" + "modlog": "moderator" } } } diff --git a/package.json b/package.json index 780b04317..cfacfd6d3 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "discord.js": "^14.25.1", "dotenv": "^17.3.1", "express": "^5.2.1", + "jsonwebtoken": "^9.0.3", "mem0ai": "^2.2.2", "pg": "^8.18.0", "winston": "^3.19.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1742141fd..234c28b23 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: express: specifier: ^5.2.1 version: 5.2.1 + jsonwebtoken: + specifier: ^9.0.3 + version: 9.0.3 mem0ai: specifier: ^2.2.2 version: 2.2.2(@anthropic-ai/sdk@0.40.1(encoding@0.1.13))(@azure/identity@4.13.0)(@azure/search-documents@12.2.0)(@cloudflare/workers-types@4.20260214.0)(@google/genai@1.41.0)(@langchain/core@0.3.80(openai@4.104.0(encoding@0.1.13)(ws@8.19.0)(zod@3.25.76)))(@mistralai/mistralai@1.14.0)(@qdrant/js-client-rest@1.13.0(typescript@5.9.3))(@supabase/supabase-js@2.95.3)(@types/jest@29.5.14)(@types/pg@8.11.0)(@types/sqlite3@3.1.11)(cloudflare@4.5.0(encoding@0.1.13))(encoding@0.1.13)(groq-sdk@0.3.0(encoding@0.1.13))(neo4j-driver@5.28.3)(ollama@0.5.18)(pg@8.18.0)(redis@4.7.1)(sqlite3@5.1.7)(ws@8.19.0) diff --git a/src/api/index.js b/src/api/index.js index 0c5506e71..ef3ba9b3c 100644 --- a/src/api/index.js +++ b/src/api/index.js @@ -5,6 +5,7 @@ import { Router } from 'express'; import { requireAuth } from './middleware/auth.js'; +import authRouter from './routes/auth.js'; import guildsRouter from './routes/guilds.js'; import healthRouter from './routes/health.js'; @@ -13,7 +14,10 @@ const router = Router(); // Health check — public (no auth required) router.use('/health', healthRouter); -// Guild routes — require API secret +// Auth routes — public (no auth required) +router.use('/auth', authRouter); + +// Guild routes — require API secret or OAuth2 JWT router.use('/guilds', requireAuth(), guildsRouter); export default router; diff --git a/src/api/middleware/auth.js b/src/api/middleware/auth.js index 2ad01f4a2..8e6ab5454 100644 --- a/src/api/middleware/auth.js +++ b/src/api/middleware/auth.js @@ -1,10 +1,11 @@ /** * Authentication Middleware - * Validates requests using a shared API secret + * Supports both shared API secret and OAuth2 JWT authentication */ import crypto from 'node:crypto'; import { warn } from '../../logger.js'; +import { handleOAuthJwt } from './oauthJwt.js'; /** * Performs a constant-time comparison of the given secret against BOT_API_SECRET. @@ -24,23 +25,46 @@ export function isValidSecret(secret) { } /** - * Creates middleware that validates the x-api-secret header against BOT_API_SECRET. - * Returns 401 JSON error if the header is missing or does not match. + * Creates middleware that validates either: + * - x-api-secret header (shared secret) — sets req.authMethod = 'api-secret' + * - Authorization: Bearer header (OAuth2) — sets req.authMethod = 'oauth', req.user = decoded JWT + * + * Returns 401 JSON error if neither is valid. * * @returns {import('express').RequestHandler} Express middleware function */ export function requireAuth() { return (req, res, next) => { - if (!process.env.BOT_API_SECRET) { - warn('BOT_API_SECRET not configured — rejecting API request'); - return res.status(401).json({ error: 'API authentication not configured' }); + // Try API secret first + const apiSecret = req.headers['x-api-secret']; + if (apiSecret) { + if (!process.env.BOT_API_SECRET) { + // API secret auth is not configured — ignore the header and fall through to JWT. + // This allows clients that always send x-api-secret to still authenticate via JWT + // when the deployer hasn't configured BOT_API_SECRET. + warn('BOT_API_SECRET not configured — ignoring x-api-secret header, trying JWT', { + ip: req.ip, + path: req.path, + }); + } else if (isValidSecret(apiSecret)) { + req.authMethod = 'api-secret'; + return next(); + } else { + // BOT_API_SECRET is configured but the provided secret doesn't match. + // Reject immediately — an explicit API-secret auth attempt that fails + // should not silently fall through to JWT. + warn('Invalid API secret provided', { ip: req.ip, path: req.path }); + return res.status(401).json({ error: 'Invalid API secret' }); + } } - if (!isValidSecret(req.headers['x-api-secret'])) { - warn('Unauthorized API request', { ip: req.ip, path: req.path }); - return res.status(401).json({ error: 'Unauthorized' }); + // Try OAuth2 JWT + if (handleOAuthJwt(req, res, next)) { + return; } - next(); + // Neither auth method provided or valid + warn('Unauthorized API request', { ip: req.ip, path: req.path }); + return res.status(401).json({ error: 'Unauthorized' }); }; } diff --git a/src/api/middleware/oauth.js b/src/api/middleware/oauth.js new file mode 100644 index 000000000..19c2557c8 --- /dev/null +++ b/src/api/middleware/oauth.js @@ -0,0 +1,18 @@ +/** + * OAuth2 JWT Middleware + * Verifies JWT tokens from Discord OAuth2 sessions + */ + +import { handleOAuthJwt } from './oauthJwt.js'; + +/** + * Creates middleware that verifies a JWT Bearer token from the Authorization header. + * Attaches the decoded user payload to req.user on success. + * + * @returns {import('express').RequestHandler} Express middleware function + */ +export function requireOAuth() { + return (req, res, next) => { + return handleOAuthJwt(req, res, next, { missingTokenError: 'No token provided' }); + }; +} diff --git a/src/api/middleware/oauthJwt.js b/src/api/middleware/oauthJwt.js new file mode 100644 index 000000000..52fabb6b2 --- /dev/null +++ b/src/api/middleware/oauthJwt.js @@ -0,0 +1,56 @@ +/** + * Shared OAuth JWT middleware helpers + */ + +import { error } from '../../logger.js'; +import { verifyJwtToken } from './verifyJwt.js'; + +/** + * Extract Bearer token from Authorization header. + * + * @param {string|undefined} authHeader - Raw Authorization header value + * @returns {string|null} JWT token if present, otherwise null + */ +export function getBearerToken(authHeader) { + if (!authHeader?.startsWith('Bearer ')) { + return null; + } + return authHeader.slice(7); +} + +/** + * Authenticate request using OAuth JWT Bearer token. + * + * @param {import('express').Request} req - Express request + * @param {import('express').Response} res - Express response + * @param {import('express').NextFunction} next - Express next callback + * @param {{ missingTokenError?: string }} [options] - Behavior options + * @returns {boolean} True if middleware chain has been handled, false if no Bearer token was provided and no missing-token error was requested + */ +export function handleOAuthJwt(req, res, next, options = {}) { + const token = getBearerToken(req.headers.authorization); + if (!token) { + if (options.missingTokenError) { + res.status(401).json({ error: options.missingTokenError }); + return true; + } + return false; + } + + const result = verifyJwtToken(token); + if (result.error) { + if (result.status === 500) { + error('SESSION_SECRET not configured — cannot verify OAuth token', { + ip: req.ip, + path: req.path, + }); + } + res.status(result.status).json({ error: result.error }); + return true; + } + + req.authMethod = 'oauth'; + req.user = result.user; + next(); + return true; +} diff --git a/src/api/middleware/verifyJwt.js b/src/api/middleware/verifyJwt.js new file mode 100644 index 000000000..270993b27 --- /dev/null +++ b/src/api/middleware/verifyJwt.js @@ -0,0 +1,50 @@ +/** + * JWT Verification Helper + * Shared JWT verification logic used by both requireAuth and requireOAuth middleware + */ + +import jwt from 'jsonwebtoken'; +import { getSessionToken } from '../utils/sessionStore.js'; + +/** + * Lazily cached SESSION_SECRET — read from env on first call, then reused. + * Avoids per-request env lookup while remaining compatible with test stubs + * (vi.stubEnv sets process.env before the first call within each test). + * Call `_resetSecretCache()` in test teardown if needed. + */ +let _cachedSecret; + +/** @internal Reset the cached secret (for test teardown). */ +export function _resetSecretCache() { + _cachedSecret = undefined; +} + +function getSecret() { + if (_cachedSecret === undefined) { + _cachedSecret = process.env.SESSION_SECRET || ''; + } + return _cachedSecret; +} + +/** + * Verify a JWT token and validate the associated server-side session. + * + * @param {string} token - The JWT Bearer token to verify + * @returns {{ user: Object } | { error: string, status: number }} + * On success: `{ user }` with the decoded JWT payload. + * On failure: `{ error, status }` with an error message and HTTP status code. + */ +export function verifyJwtToken(token) { + const secret = getSecret(); + if (!secret) return { error: 'Session not configured', status: 500 }; + + try { + const decoded = jwt.verify(token, secret, { algorithms: ['HS256'] }); + if (!getSessionToken(decoded.userId)) { + return { error: 'Session expired or revoked', status: 401 }; + } + return { user: decoded }; + } catch { + return { error: 'Invalid or expired token', status: 401 }; + } +} diff --git a/src/api/routes/auth.js b/src/api/routes/auth.js new file mode 100644 index 000000000..f784982c6 --- /dev/null +++ b/src/api/routes/auth.js @@ -0,0 +1,291 @@ +/** + * Auth Routes + * Discord OAuth2 authentication endpoints + */ + +import crypto from 'node:crypto'; +import { Router } from 'express'; +import jwt from 'jsonwebtoken'; +import { error, info, warn } from '../../logger.js'; +import { requireOAuth } from '../middleware/oauth.js'; +import { DISCORD_API, fetchUserGuilds } from '../utils/discordApi.js'; +import { sessionStore } from '../utils/sessionStore.js'; + +const router = Router(); + +// Note: sessionStore canonical home is '../utils/sessionStore.js'. +// Import directly from there, not from this file. + +/** CSRF state store: state → expiry timestamp */ +const oauthStates = new Map(); +const STATE_TTL_MS = 10 * 60 * 1000; // 10 minutes +const MAX_OAUTH_STATES = 10_000; + +/** + * Seed an OAuth state for testing purposes. + * Adds a state entry with the default TTL so integration tests can exercise + * the callback endpoint without performing the redirect flow. + * + * @param {string} state - The state value to seed + */ +export function _seedOAuthState(state) { + if (process.env.NODE_ENV === 'production') { + throw new Error('_seedOAuthState is not available in production'); + } + oauthStates.set(state, Date.now() + STATE_TTL_MS); +} + +const CLEANUP_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes + +/** Allowed dashboard URL hostnames for HTTP redirect validation */ +const ALLOWED_REDIRECT_HOSTS = new Set(['localhost', '127.0.0.1', '::1']); + +/** + * Validate dashboard redirect target from environment config. + * Accepts HTTPS URLs for production and HTTP only for localhost/loopback. + * Uses URL parsing to prevent prefix-matching attacks (e.g., http://localhost.evil.com). + * + * @param {string|undefined} value - DASHBOARD_URL from environment + * @returns {boolean} True if URL is allowed + */ +function isValidDashboardUrl(value) { + if (typeof value !== 'string' || value.trim().length === 0) return false; + try { + const parsed = new URL(value); + if (parsed.protocol === 'https:') return true; + if (parsed.protocol === 'http:' && ALLOWED_REDIRECT_HOSTS.has(parsed.hostname)) return true; + return false; + } catch { + return false; + } +} + +/** + * Remove expired state entries from the store + */ +function cleanExpiredStates() { + const now = Date.now(); + for (const [key, expiry] of oauthStates) { + if (now >= expiry) oauthStates.delete(key); + } +} + +/** + * Remove expired session entries from the store + */ +function cleanExpiredSessions() { + sessionStore.cleanup(); +} + +/** Periodic cleanup interval for expired OAuth states */ +const stateCleanupInterval = setInterval(cleanExpiredStates, CLEANUP_INTERVAL_MS); +stateCleanupInterval.unref(); + +/** Periodic cleanup interval for expired sessions */ +const sessionCleanupInterval = setInterval(cleanExpiredSessions, CLEANUP_INTERVAL_MS); +sessionCleanupInterval.unref(); + +/** + * Stop all periodic cleanup intervals (state + session). + * Should be called during server shutdown. + */ +export function stopAuthCleanup() { + clearInterval(stateCleanupInterval); + clearInterval(sessionCleanupInterval); +} + +/** + * GET /discord — Redirect to Discord OAuth2 authorization + */ +router.get('/discord', (_req, res) => { + const clientId = process.env.DISCORD_CLIENT_ID; + const redirectUri = process.env.DISCORD_REDIRECT_URI; + + if (!clientId || !redirectUri) { + error('OAuth2 not configured for /discord', { + hasClientId: Boolean(clientId), + hasRedirectUri: Boolean(redirectUri), + }); + return res.status(500).json({ error: 'OAuth2 not configured' }); + } + + cleanExpiredStates(); + + const state = crypto.randomUUID(); + oauthStates.set(state, Date.now() + STATE_TTL_MS); + // Cap state store size to prevent unbounded memory growth + if (oauthStates.size > MAX_OAUTH_STATES) { + const oldest = oauthStates.keys().next().value; + oauthStates.delete(oldest); + } + + const params = new URLSearchParams({ + client_id: clientId, + redirect_uri: redirectUri, + response_type: 'code', + scope: 'identify guilds', + state, + }); + + res.redirect(`https://discord.com/oauth2/authorize?${params}`); +}); + +/** + * GET /discord/callback — Handle Discord OAuth2 callback + * Exchanges code for token, fetches user info, creates JWT + */ +router.get('/discord/callback', async (req, res) => { + cleanExpiredStates(); + + const { code, state } = req.query; + + if (!code) { + return res.status(400).json({ error: 'Missing authorization code' }); + } + + // Validate CSRF state parameter + if (!state || !oauthStates.has(state)) { + return res.status(403).json({ error: 'Invalid or expired OAuth state' }); + } + + const stateExpiry = oauthStates.get(state); + oauthStates.delete(state); + + if (!stateExpiry || Date.now() >= stateExpiry) { + return res.status(403).json({ error: 'Invalid or expired OAuth state' }); + } + + const clientId = process.env.DISCORD_CLIENT_ID; + const clientSecret = process.env.DISCORD_CLIENT_SECRET; + const redirectUri = process.env.DISCORD_REDIRECT_URI; + const sessionSecret = process.env.SESSION_SECRET; + + if (!clientId || !clientSecret || !redirectUri || !sessionSecret) { + error('OAuth2 not configured for /discord/callback', { + hasClientId: Boolean(clientId), + hasClientSecret: Boolean(clientSecret), + hasRedirectUri: Boolean(redirectUri), + hasSessionSecret: Boolean(sessionSecret), + }); + return res.status(500).json({ error: 'OAuth2 not configured' }); + } + + try { + // Exchange code for access token + const tokenResponse = await fetch(`${DISCORD_API}/oauth2/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + client_id: clientId, + client_secret: clientSecret, + grant_type: 'authorization_code', + code, + redirect_uri: redirectUri, + }), + signal: AbortSignal.timeout(10_000), + }); + + if (!tokenResponse.ok) { + error('Discord token exchange failed', { status: tokenResponse.status }); + return res.status(401).json({ error: 'Failed to exchange authorization code' }); + } + + const tokenData = await tokenResponse.json(); + const accessToken = tokenData.access_token; + if (typeof accessToken !== 'string' || accessToken.trim().length === 0) { + error('Discord token exchange returned invalid payload', { + hasAccessToken: Object.hasOwn(tokenData, 'access_token'), + accessTokenType: typeof accessToken, + }); + return res.status(502).json({ error: 'Invalid response from Discord' }); + } + + // Fetch user info + const userResponse = await fetch(`${DISCORD_API}/users/@me`, { + headers: { Authorization: `Bearer ${accessToken}` }, + signal: AbortSignal.timeout(10_000), + }); + + if (!userResponse.ok) { + error('Discord user fetch failed', { status: userResponse.status }); + return res.status(401).json({ error: 'Failed to fetch user info' }); + } + + const user = await userResponse.json(); + if (typeof user?.id !== 'string' || user.id.trim().length === 0) { + error('Discord user fetch returned invalid payload', { + hasUserId: Object.hasOwn(user ?? {}, 'id'), + userIdType: typeof user?.id, + }); + return res.status(502).json({ error: 'Invalid response from Discord' }); + } + + // Store access token server-side (never in the JWT) + sessionStore.set(user.id, accessToken); + + // Create JWT with user info only (no access token — stored server-side) + const token = jwt.sign( + { + userId: user.id, + username: user.username, + avatar: user.avatar, + }, + sessionSecret, + { algorithm: 'HS256', expiresIn: '1h' }, + ); + + info('User authenticated via OAuth2', { userId: user.id }); + + // DASHBOARD_URL is admin-configured environment input, not user-controlled request data. + // Redirect with token as fragment to avoid server-side logging. + const dashboardUrl = isValidDashboardUrl(process.env.DASHBOARD_URL) + ? process.env.DASHBOARD_URL + : '/'; + if (dashboardUrl === '/' && process.env.DASHBOARD_URL) { + warn('Invalid DASHBOARD_URL; falling back to root redirect', { + dashboardUrl: process.env.DASHBOARD_URL, + }); + } + // Strip existing fragment to avoid collision, then append token + const redirectBase = dashboardUrl.includes('#') ? dashboardUrl.split('#')[0] : dashboardUrl; + res.redirect(`${redirectBase}#token=${token}`); + } catch (err) { + error('OAuth2 callback error', { error: err.message }); + res.status(500).json({ error: 'Authentication failed' }); + } +}); + +/** + * GET /me — Return current authenticated user info from JWT + * Fetches fresh guilds from Discord using the stored access token + */ +router.get('/me', requireOAuth(), async (req, res) => { + const { userId, username, avatar } = req.user; + const accessToken = sessionStore.get(userId); + + let guilds = []; + if (accessToken) { + try { + const userGuilds = await fetchUserGuilds(userId, accessToken); + guilds = userGuilds.map((g) => ({ + id: g.id, + name: g.name, + permissions: g.permissions, + })); + } catch (fetchErr) { + error('Failed to fetch guilds for /me', { error: fetchErr.message, userId }); + } + } + + res.json({ userId, username, avatar, guilds }); +}); + +/** + * POST /logout — Invalidate the user's server-side session + */ +router.post('/logout', requireOAuth(), (req, res) => { + sessionStore.delete(req.user.userId); + res.json({ message: 'Logged out successfully' }); +}); + +export default router; diff --git a/src/api/routes/guilds.js b/src/api/routes/guilds.js index 87df8d464..a64595d45 100644 --- a/src/api/routes/guilds.js +++ b/src/api/routes/guilds.js @@ -4,12 +4,19 @@ */ import { Router } from 'express'; -import { error, info } from '../../logger.js'; +import { error, info, warn } from '../../logger.js'; import { getConfig, setConfigValue } from '../../modules/config.js'; import { safeSend } from '../../utils/safeSend.js'; +import { fetchUserGuilds } from '../utils/discordApi.js'; +import { getSessionToken } from '../utils/sessionStore.js'; const router = Router(); +/** Discord ADMINISTRATOR permission flag */ +const ADMINISTRATOR_FLAG = 0x8; +/** Discord MANAGE_GUILD permission flag */ +const MANAGE_GUILD_FLAG = 0x20; + /** * Config keys that are safe to write via the PATCH endpoint. * 'moderation' is intentionally excluded to prevent API callers from @@ -48,6 +55,112 @@ function parsePagination(query) { return { page, limit, offset }; } +/** + * Check if an OAuth2 user has the specified permission flags on a guild. + * Fetches fresh guild list from Discord using the access token from the session store. + * + * @param {Object} user - Decoded JWT user payload + * @param {string} guildId - Guild ID to check + * @param {number} requiredFlags - Bitmask of required permission flags (bitwise OR match) + * @returns {Promise} True if user has ANY of the required flags (bitwise OR match) + */ +async function hasOAuthGuildPermission(user, guildId, requiredFlags) { + const accessToken = getSessionToken(user?.userId); + if (!accessToken) return false; + const guilds = await fetchUserGuilds(user.userId, accessToken); + const guild = guilds.find((g) => g.id === guildId); + if (!guild) return false; + const permissions = Number(guild.permissions); + if (Number.isNaN(permissions)) return false; + return (permissions & requiredFlags) !== 0; +} + +/** + * Check whether the authenticated OAuth2 user is a configured bot owner. + * Bot owners bypass API guild-level permission checks. + * + * @param {Object} user - Decoded JWT user payload + * @returns {boolean} True if JWT userId is in config.permissions.botOwners + */ +function isOAuthBotOwner(user) { + const botOwners = getConfig()?.permissions?.botOwners; + return Array.isArray(botOwners) && botOwners.includes(user?.userId); +} + +/** + * Check if an OAuth2 user has admin permissions on a guild. + * Admin = ADMINISTRATOR only, aligning with the slash-command isAdmin check. + * + * @param {Object} user - Decoded JWT user payload + * @param {string} guildId - Guild ID to check + * @returns {Promise} True if user has admin-level permission + */ +function isOAuthGuildAdmin(user, guildId) { + return hasOAuthGuildPermission(user, guildId, ADMINISTRATOR_FLAG); +} + +/** + * Check if an OAuth2 user has moderator permissions on a guild. + * Moderator = ADMINISTRATOR or MANAGE_GUILD, aligning with the slash-command isModerator check. + * + * @param {Object} user - Decoded JWT user payload + * @param {string} guildId - Guild ID to check + * @returns {Promise} True if user has moderator-level permission + */ +function isOAuthGuildModerator(user, guildId) { + return hasOAuthGuildPermission(user, guildId, ADMINISTRATOR_FLAG | MANAGE_GUILD_FLAG); +} + +/** + * Create middleware that verifies OAuth2 users have the required guild permission. + * API-secret users and configured bot owners are trusted and pass through. + * + * @param {(user: Object, guildId: string) => Promise} permissionCheck - Permission check function + * @param {string} errorMessage - Error message for 403 responses + * @returns {import('express').RequestHandler} + */ +function requireGuildPermission(permissionCheck, errorMessage) { + return async (req, res, next) => { + if (req.authMethod === 'api-secret') return next(); + + if (req.authMethod === 'oauth') { + if (isOAuthBotOwner(req.user)) return next(); + + try { + if (!(await permissionCheck(req.user, req.params.id))) { + return res.status(403).json({ error: errorMessage }); + } + return next(); + } catch (err) { + error('Failed to verify guild permission', { + error: err.message, + guild: req.params.id, + userId: req.user?.userId, + }); + return res.status(502).json({ error: 'Failed to verify guild permissions with Discord' }); + } + } + + warn('Unknown authMethod in guild permission check', { + authMethod: req.authMethod, + path: req.path, + }); + return res.status(401).json({ error: 'Unauthorized' }); + }; +} + +/** Middleware: verify OAuth2 users are guild admins. API-secret users pass through. */ +const requireGuildAdmin = requireGuildPermission( + isOAuthGuildAdmin, + 'You do not have admin access to this guild', +); + +/** Middleware: verify OAuth2 users are guild moderators. API-secret users pass through. */ +const requireGuildModerator = requireGuildPermission( + isOAuthGuildModerator, + 'You do not have moderator access to this guild', +); + /** * Middleware: validate guild ID param and attach guild to req. * Returns 404 if the bot is not in the requested guild. @@ -64,13 +177,86 @@ function validateGuild(req, res, next) { next(); } -// Apply guild validation to all routes with :id param -router.param('id', validateGuild); +/** + * GET / — List guilds + * For OAuth2 users: + * - bot owners: return all guilds where the bot is present (access = "bot-owner") + * - non-owners: fetch fresh guilds from Discord and return only guilds where user has + * ADMINISTRATOR (access = "admin") or MANAGE_GUILD (access = "moderator"), and bot is present + * For api-secret users: returns all bot guilds + */ +router.get('/', async (req, res) => { + const { client } = req.app.locals; + const botGuilds = client.guilds.cache; + + if (req.authMethod === 'oauth') { + if (isOAuthBotOwner(req.user)) { + const ownerGuilds = Array.from(botGuilds.values()).map((g) => ({ + id: g.id, + name: g.name, + icon: g.iconURL(), + memberCount: g.memberCount, + access: 'bot-owner', + })); + return res.json(ownerGuilds); + } + + const accessToken = getSessionToken(req.user?.userId); + if (!accessToken) { + return res.status(401).json({ error: 'Missing access token' }); + } + + try { + const userGuilds = await fetchUserGuilds(req.user.userId, accessToken); + const filtered = userGuilds.reduce((acc, ug) => { + const permissions = Number(ug.permissions); + const hasAdmin = (permissions & ADMINISTRATOR_FLAG) !== 0; + const hasManageGuild = (permissions & MANAGE_GUILD_FLAG) !== 0; + const access = hasAdmin ? 'admin' : hasManageGuild ? 'moderator' : null; + if (!access) return acc; + + // Single lookup avoids has/get TOCTOU. + const botGuild = botGuilds.get(ug.id); + if (!botGuild) return acc; + acc.push({ + id: ug.id, + name: botGuild.name, + icon: botGuild.iconURL(), + memberCount: botGuild.memberCount, + access, + }); + return acc; + }, []); + + return res.json(filtered); + } catch (err) { + error('Failed to fetch user guilds from Discord', { + error: err.message, + userId: req.user?.userId, + }); + return res.status(502).json({ error: 'Failed to fetch guilds from Discord' }); + } + } + + if (req.authMethod === 'api-secret') { + const guilds = Array.from(botGuilds.values()).map((g) => ({ + id: g.id, + name: g.name, + icon: g.iconURL(), + memberCount: g.memberCount, + })); + return res.json(guilds); + } + + // Unknown auth method — reject + warn('Unknown authMethod in guild list', { authMethod: req.authMethod, path: req.path }); + return res.status(401).json({ error: 'Unauthorized' }); +}); /** * GET /:id — Guild info */ -router.get('/:id', (req, res) => { +router.get('/:id', requireGuildAdmin, validateGuild, (req, res) => { const guild = req.guild; const MAX_CHANNELS = 500; const channels = []; @@ -97,7 +283,7 @@ router.get('/:id', (req, res) => { * API consistency but does not scope the returned config. * Per-guild config is tracked in Issue #71. */ -router.get('/:id/config', (req, res) => { +router.get('/:id/config', requireGuildAdmin, validateGuild, (_req, res) => { const config = getConfig(); const safeConfig = {}; for (const key of READABLE_CONFIG_KEYS) { @@ -119,7 +305,7 @@ router.get('/:id/config', (req, res) => { * API consistency but does not scope the update. * Per-guild config is tracked in Issue #71. */ -router.patch('/:id/config', async (req, res) => { +router.patch('/:id/config', requireGuildAdmin, validateGuild, async (req, res) => { if (!req.body) { return res.status(400).json({ error: 'Request body is required' }); } @@ -163,7 +349,7 @@ router.patch('/:id/config', async (req, res) => { /** * GET /:id/stats — Guild statistics */ -router.get('/:id/stats', async (req, res) => { +router.get('/:id/stats', requireGuildAdmin, validateGuild, async (req, res) => { const { dbPool } = req.app.locals; if (!dbPool) { @@ -203,7 +389,7 @@ router.get('/:id/stats', async (req, res) => { * Query params: ?limit=25&after= (max 100) * Uses Discord's cursor-based pagination via guild.members.list(). */ -router.get('/:id/members', async (req, res) => { +router.get('/:id/members', requireGuildAdmin, validateGuild, async (req, res) => { let limit = Number.parseInt(req.query.limit, 10) || 25; if (limit < 1) limit = 1; if (limit > 100) limit = 100; @@ -238,7 +424,7 @@ router.get('/:id/members', async (req, res) => { * GET /:id/moderation — Paginated moderation cases * Query params: ?page=1&limit=25 (max 100) */ -router.get('/:id/moderation', async (req, res) => { +router.get('/:id/moderation', requireGuildModerator, validateGuild, async (req, res) => { const { dbPool } = req.app.locals; if (!dbPool) { @@ -280,7 +466,7 @@ router.get('/:id/moderation', async (req, res) => { * POST /:id/actions — Execute bot actions * Body: { action: "sendMessage", channelId: "...", content: "..." } */ -router.post('/:id/actions', async (req, res) => { +router.post('/:id/actions', requireGuildAdmin, validateGuild, async (req, res) => { if (!req.body) { return res.status(400).json({ error: 'Missing request body' }); } diff --git a/src/api/server.js b/src/api/server.js index 93b72187a..3e4023f64 100644 --- a/src/api/server.js +++ b/src/api/server.js @@ -7,6 +7,8 @@ import express from 'express'; import { error, info, warn } from '../logger.js'; import apiRouter from './index.js'; import { rateLimit } from './middleware/rateLimit.js'; +import { stopAuthCleanup } from './routes/auth.js'; +import { stopGuildCacheCleanup } from './utils/discordApi.js'; /** @type {import('node:http').Server | null} */ let server = null; @@ -37,7 +39,7 @@ export function createApp(client, dbPool) { if (!dashboardUrl) return next(); res.set('Access-Control-Allow-Origin', dashboardUrl); res.set('Access-Control-Allow-Methods', 'GET, POST, PATCH, OPTIONS'); - res.set('Access-Control-Allow-Headers', 'Content-Type, x-api-secret'); + res.set('Access-Control-Allow-Headers', 'Content-Type, x-api-secret, Authorization'); if (req.method === 'OPTIONS') { return res.status(204).end(); } @@ -122,6 +124,9 @@ export async function startServer(client, dbPool) { * @returns {Promise} */ export async function stopServer() { + stopAuthCleanup(); + stopGuildCacheCleanup(); + if (rateLimiter) { rateLimiter.destroy(); rateLimiter = null; diff --git a/src/api/utils/discordApi.js b/src/api/utils/discordApi.js new file mode 100644 index 000000000..b8b10e932 --- /dev/null +++ b/src/api/utils/discordApi.js @@ -0,0 +1,72 @@ +/** + * Discord API Utilities + * Shared helpers for fetching data from the Discord REST API with caching + */ + +import { error } from '../../logger.js'; +import { DiscordApiError } from '../../utils/errors.js'; + +/** Guild cache: userId → { guilds, expiresAt } */ +export const guildCache = new Map(); +const GUILD_CACHE_TTL_MS = 90_000; // 90 seconds +const MAX_GUILD_CACHE_SIZE = 10_000; +export const DISCORD_API = 'https://discord.com/api/v10'; + +function cleanExpiredGuildCache() { + const now = Date.now(); + for (const [key, entry] of guildCache.entries()) { + if (now >= entry.expiresAt) guildCache.delete(key); + } +} + +const guildCacheCleanupInterval = setInterval(cleanExpiredGuildCache, 60_000); +guildCacheCleanupInterval.unref(); + +export function stopGuildCacheCleanup() { + clearInterval(guildCacheCleanupInterval); +} + +/** + * Fetch guilds from Discord using the user's access token, with a short-lived cache. + * + * @param {string} userId - User ID (cache key) + * @param {string} accessToken - Discord OAuth2 access token + * @returns {Promise} Array of guild objects + */ +export async function fetchUserGuilds(userId, accessToken) { + if (typeof accessToken !== 'string' || accessToken.trim().length === 0) { + error('Invalid access token for guild fetch', { + userId, + accessTokenType: typeof accessToken, + }); + throw new Error('Invalid access token'); + } + + const cached = guildCache.get(userId); + if (cached) { + if (Date.now() < cached.expiresAt) { + return cached.guilds; + } + guildCache.delete(userId); + } + + const response = await fetch(`${DISCORD_API}/users/@me/guilds`, { + headers: { Authorization: `Bearer ${accessToken}` }, + signal: AbortSignal.timeout(10_000), + }); + if (!response.ok) { + const status = response.status; + error('Discord guild fetch failed', { userId, status }); + throw new DiscordApiError('Discord API error', status); + } + const guilds = await response.json(); + if (!Array.isArray(guilds)) throw new Error('Discord API returned non-array guild data'); + + guildCache.set(userId, { guilds, expiresAt: Date.now() + GUILD_CACHE_TTL_MS }); + // Cap cache size to prevent unbounded memory growth + while (guildCache.size > MAX_GUILD_CACHE_SIZE) { + const oldest = guildCache.keys().next().value; + guildCache.delete(oldest); + } + return guilds; +} diff --git a/src/api/utils/sessionStore.js b/src/api/utils/sessionStore.js new file mode 100644 index 000000000..4e7b28d4d --- /dev/null +++ b/src/api/utils/sessionStore.js @@ -0,0 +1,76 @@ +/** + * Session Store Utilities + * Shared OAuth session token storage and helpers + */ + +/** + * Session TTL — must match the JWT `expiresIn` value in auth.js (currently "1h"). + * If you change one, update the other to keep session and token lifetimes aligned. + */ +const SESSION_TTL_MS = 60 * 60 * 1000; // 1 hour + +/** + * TTL-based in-memory session store: userId → { accessToken, expiresAt }. + * Extends Map to transparently handle expiry on get/has. + * + * ### Scaling Limitations + * + * This store is **in-memory and single-process only**: + * - Sessions are lost on server restart. + * - Cannot be shared across multiple Node.js processes or containers. + * - Memory grows linearly with active sessions (mitigated by TTL + cleanup). + * + * ### Future Migration + * + * For multi-instance deployments, replace with a shared store (e.g., Redis): + * 1. Swap this class for a Redis-backed adapter with the same get/set/has/delete interface. + * 2. Use Redis TTL (`SETEX`) instead of manual expiry tracking. + * 3. Update `cleanup()` to rely on Redis key expiration. + * + * ### Map Override Coverage + * + * Only `get`, `set`, `has`, and `delete` are overridden to handle TTL. + * Inherited methods like `size`, `forEach`, `entries`, `keys`, `values` + * operate on the raw Map entries (including expired ones between cleanup cycles). + * The periodic `cleanup()` call purges expired entries to keep these reasonable. + * For most use cases (auth lookups by userId), the overridden methods suffice. + */ +class SessionStore extends Map { + set(userId, accessToken) { + return super.set(userId, { accessToken, expiresAt: Date.now() + SESSION_TTL_MS }); + } + + get(userId) { + const entry = super.get(userId); + if (!entry) return undefined; + if (Date.now() >= entry.expiresAt) { + super.delete(userId); + return undefined; + } + return entry.accessToken; + } + + has(userId) { + return this.get(userId) !== undefined; + } + + cleanup() { + const now = Date.now(); + for (const [key, entry] of super.entries()) { + if (now >= entry.expiresAt) super.delete(key); + } + } +} + +export const sessionStore = new SessionStore(); + +/** + * Get the access token for a user from the session store. + * Returns undefined if the session has expired or does not exist. + * + * @param {string} userId - Discord user ID + * @returns {string|undefined} The access token, or undefined + */ +export function getSessionToken(userId) { + return sessionStore.get(userId); +} diff --git a/src/commands/config.js b/src/commands/config.js index 13e402903..4ca2c5e7c 100644 --- a/src/commands/config.js +++ b/src/commands/config.js @@ -5,6 +5,7 @@ import { EmbedBuilder, SlashCommandBuilder } from 'discord.js'; import { getConfig, resetConfig, setConfigValue } from '../modules/config.js'; +import { getPermissionError, hasPermission } from '../utils/permissions.js'; import { safeEditReply, safeReply } from '../utils/safeSend.js'; /** @@ -157,6 +158,15 @@ export async function autocomplete(interaction) { * @param {Object} interaction - Discord interaction */ export async function execute(interaction) { + const config = getConfig(); + if (!hasPermission(interaction.member, 'config', config)) { + const permLevel = config.permissions?.allowedCommands?.config || 'administrator'; + return await safeReply(interaction, { + content: getPermissionError('config', permLevel), + ephemeral: true, + }); + } + const subcommand = interaction.options.getSubcommand(); switch (subcommand) { diff --git a/src/commands/modlog.js b/src/commands/modlog.js index c46ba0d73..6da75474f 100644 --- a/src/commands/modlog.js +++ b/src/commands/modlog.js @@ -15,6 +15,7 @@ import { } from 'discord.js'; import { info, error as logError } from '../logger.js'; import { getConfig, setConfigValue } from '../modules/config.js'; +import { getPermissionError, hasPermission } from '../utils/permissions.js'; import { safeEditReply, safeReply } from '../utils/safeSend.js'; export const data = new SlashCommandBuilder() @@ -31,6 +32,15 @@ export const adminOnly = true; * @param {import('discord.js').ChatInputCommandInteraction} interaction */ export async function execute(interaction) { + const config = getConfig(); + if (!hasPermission(interaction.member, 'modlog', config)) { + const permLevel = config.permissions?.allowedCommands?.modlog || 'administrator'; + return await safeReply(interaction, { + content: getPermissionError('modlog', permLevel), + ephemeral: true, + }); + } + const subcommand = interaction.options.getSubcommand(); switch (subcommand) { diff --git a/src/index.js b/src/index.js index dcbd850dc..364f5aebe 100644 --- a/src/index.js +++ b/src/index.js @@ -186,8 +186,9 @@ client.on('interactionCreate', async (interaction) => { // Permission check if (!hasPermission(member, commandName, config)) { + const permLevel = config.permissions?.allowedCommands?.[commandName] || 'administrator'; await safeReply(interaction, { - content: getPermissionError(commandName), + content: getPermissionError(commandName, permLevel), ephemeral: true, }); warn('Permission denied', { user: interaction.user.tag, command: commandName }); @@ -316,6 +317,17 @@ async function startup() { // Load config (from DB if available, else config.json) config = await loadConfig(); info('Configuration loaded', { sections: Object.keys(config) }); + // Warn if using default bot owner ID (upstream maintainer) + const defaultOwnerId = '191633014441115648'; + const owners = config.permissions?.botOwners; + if (Array.isArray(owners) && owners.includes(defaultOwnerId)) { + warn( + 'Default botOwners detected in config — update permissions.botOwners with your own Discord user ID(s) before deploying', + { + defaultOwnerId, + }, + ); + } // Register config change listeners for hot-reload // diff --git a/src/utils/errors.js b/src/utils/errors.js index 5d0e40af2..c9f4a785f 100644 --- a/src/utils/errors.js +++ b/src/utils/errors.js @@ -33,6 +33,21 @@ export const ErrorType = { UNKNOWN: 'unknown', }; +/** + * Custom error for Discord API failures, carrying the HTTP status code. + */ +export class DiscordApiError extends Error { + /** + * @param {string} message - Human-readable error description + * @param {number} status - HTTP status code from Discord + */ + constructor(message, status) { + super(message); + this.name = 'DiscordApiError'; + this.status = status; + } +} + /** * Classify an error into a specific error type * diff --git a/src/utils/permissions.js b/src/utils/permissions.js index 206b9f779..2c220f724 100644 --- a/src/utils/permissions.js +++ b/src/utils/permissions.js @@ -6,6 +6,22 @@ import { PermissionFlagsBits } from 'discord.js'; +/** + * Check if a member is a bot owner + * + * @param {GuildMember} member - Discord guild member + * @param {Object} config - Bot configuration + * @returns {boolean} True if member is a bot owner + */ +function isBotOwner(member, config) { + const owners = config?.permissions?.botOwners; + if (!Array.isArray(owners) || owners.length === 0) { + return false; + } + const userId = member?.id || member?.user?.id; + return userId != null && owners.includes(userId); +} + /** * Check if a member is an admin * @@ -14,7 +30,12 @@ import { PermissionFlagsBits } from 'discord.js'; * @returns {boolean} True if member is admin */ export function isAdmin(member, config) { - if (!member || !config) return false; + if (!member) return false; + + // Bot owner always bypasses permission checks + if (isBotOwner(member, config)) return true; + + if (!config) return false; // Check if member has Discord Administrator permission if (member.permissions.has(PermissionFlagsBits.Administrator)) { @@ -38,7 +59,12 @@ export function isAdmin(member, config) { * @returns {boolean} True if member has permission */ export function hasPermission(member, commandName, config) { - if (!member || !commandName || !config) return false; + if (!member || !commandName) return false; + + // Bot owner always bypasses permission checks + if (isBotOwner(member, config)) return true; + + if (!config) return false; // If permissions are disabled, allow everything if (!config.permissions?.enabled || !config.permissions?.usePermissions) { @@ -50,7 +76,7 @@ export function hasPermission(member, commandName, config) { // If command not in config, default to admin-only for safety if (!permissionLevel) { - return isAdmin(member, config); + return isGuildAdmin(member, config); } // Check permission level @@ -58,20 +84,81 @@ export function hasPermission(member, commandName, config) { return true; } + if (permissionLevel === 'moderator') { + return isModerator(member, config); + } + if (permissionLevel === 'admin') { - return isAdmin(member, config); + return isGuildAdmin(member, config); } // Unknown permission level - deny for safety return false; } +/** + * Check if a member is a guild admin (has ADMINISTRATOR permission or bot admin role). + * + * Currently delegates to {@link isAdmin}. This is an intentional alias to establish + * a separate semantic entry-point for per-guild admin checks. When per-guild config + * lands (Issue #71), this function will diverge to check guild-scoped admin roles + * instead of the global bot admin role. + * + * @param {GuildMember} member - Discord guild member + * @param {Object} config - Bot configuration + * @returns {boolean} True if member is a guild admin + */ +export function isGuildAdmin(member, config) { + // TODO(#71): check guild-scoped admin roles once per-guild config is implemented + return isAdmin(member, config); +} + +/** + * Check if a member is a moderator (has MANAGE_GUILD permission or bot admin role) + * + * @param {GuildMember} member - Discord guild member + * @param {Object} config - Bot configuration + * @returns {boolean} True if member is a moderator + */ +export function isModerator(member, config) { + if (!member) return false; + + // Bot owner always returns true + if (isBotOwner(member, config)) return true; + + if (!config) return false; + + // Administrator is strictly higher privilege — always implies moderator + if (member.permissions.has(PermissionFlagsBits.Administrator)) { + return true; + } + + // Check Discord Manage Guild permission + if (member.permissions.has(PermissionFlagsBits.ManageGuild)) { + return true; + } + + // Check bot admin role from config + if (config.permissions?.adminRoleId) { + if (member.roles.cache.has(config.permissions.adminRoleId)) { + return true; + } + } + + if (config.permissions?.moderatorRoleId) { + return member.roles.cache.has(config.permissions.moderatorRoleId); + } + + return false; +} + /** * Get a helpful error message for permission denied * * @param {string} commandName - Name of the command + * @param {string} [level='administrator'] - Required permission level * @returns {string} User-friendly error message */ -export function getPermissionError(commandName) { - return `❌ You don't have permission to use \`/${commandName}\`.\n\nThis command requires administrator access.`; +export function getPermissionError(commandName, level = 'administrator') { + return `❌ You don't have permission to use \`/${commandName}\`.\n\nThis command requires ${level} access.`; } diff --git a/tests/api/middleware/auth.test.js b/tests/api/middleware/auth.test.js index 27f6e36a3..9a25d60e3 100644 --- a/tests/api/middleware/auth.test.js +++ b/tests/api/middleware/auth.test.js @@ -1,8 +1,11 @@ +import jwt from 'jsonwebtoken'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; vi.mock('../../../src/logger.js', () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn() })); import { isValidSecret, requireAuth } from '../../../src/api/middleware/auth.js'; +import { _resetSecretCache } from '../../../src/api/middleware/verifyJwt.js'; +import { sessionStore } from '../../../src/api/utils/sessionStore.js'; describe('isValidSecret', () => { afterEach(() => { @@ -50,18 +53,39 @@ describe('auth middleware', () => { }); afterEach(() => { + sessionStore.clear(); + _resetSecretCache(); vi.clearAllMocks(); vi.unstubAllEnvs(); }); - it('should return 401 when BOT_API_SECRET is not configured', () => { + it('should fall back to JWT auth when BOT_API_SECRET is not configured', () => { vi.stubEnv('BOT_API_SECRET', ''); + vi.stubEnv('SESSION_SECRET', 'jwt-test-secret'); + req.headers['x-api-secret'] = 'some-secret'; + sessionStore.set('999', 'discord-access-token'); + const token = jwt.sign({ userId: '999', username: 'testuser' }, 'jwt-test-secret', { + algorithm: 'HS256', + }); + req.headers.authorization = `Bearer ${token}`; + const middleware = requireAuth(); + + middleware(req, res, next); + + expect(next).toHaveBeenCalled(); + expect(req.authMethod).toBe('oauth'); + expect(req.user.userId).toBe('999'); + }); + + it('should return 401 when BOT_API_SECRET is not configured and no Bearer token is provided', () => { + vi.stubEnv('BOT_API_SECRET', ''); + req.headers['x-api-secret'] = 'some-secret'; const middleware = requireAuth(); middleware(req, res, next); expect(res.status).toHaveBeenCalledWith(401); - expect(res.json).toHaveBeenCalledWith({ error: 'API authentication not configured' }); + expect(res.json).toHaveBeenCalledWith({ error: 'Unauthorized' }); expect(next).not.toHaveBeenCalled(); }); @@ -76,7 +100,19 @@ describe('auth middleware', () => { expect(next).not.toHaveBeenCalled(); }); - it('should return 401 when x-api-secret header does not match', () => { + it('should return Unauthorized when Authorization header is not Bearer and no API secret succeeds', () => { + vi.stubEnv('BOT_API_SECRET', ''); + req.headers.authorization = 'Basic abc123'; + const middleware = requireAuth(); + + middleware(req, res, next); + + expect(res.status).toHaveBeenCalledWith(401); + expect(res.json).toHaveBeenCalledWith({ error: 'Unauthorized' }); + expect(next).not.toHaveBeenCalled(); + }); + + it('should return 401 with specific error when x-api-secret does not match', () => { vi.stubEnv('BOT_API_SECRET', 'test-secret'); req.headers['x-api-secret'] = 'wrong-secret'; const middleware = requireAuth(); @@ -84,7 +120,7 @@ describe('auth middleware', () => { middleware(req, res, next); expect(res.status).toHaveBeenCalledWith(401); - expect(res.json).toHaveBeenCalledWith({ error: 'Unauthorized' }); + expect(res.json).toHaveBeenCalledWith({ error: 'Invalid API secret' }); expect(next).not.toHaveBeenCalled(); }); @@ -96,6 +132,62 @@ describe('auth middleware', () => { middleware(req, res, next); expect(next).toHaveBeenCalled(); + expect(req.authMethod).toBe('api-secret'); expect(res.status).not.toHaveBeenCalled(); }); + + it('should authenticate with valid JWT Bearer token', () => { + vi.stubEnv('SESSION_SECRET', 'jwt-test-secret'); + sessionStore.set('123', 'discord-access-token'); + const token = jwt.sign({ userId: '123', username: 'testuser' }, 'jwt-test-secret', { + algorithm: 'HS256', + }); + req.headers.authorization = `Bearer ${token}`; + const middleware = requireAuth(); + + middleware(req, res, next); + + expect(next).toHaveBeenCalled(); + expect(req.authMethod).toBe('oauth'); + expect(req.user.userId).toBe('123'); + }); + + it('should return 401 for invalid JWT Bearer token', () => { + vi.stubEnv('SESSION_SECRET', 'jwt-test-secret'); + req.headers.authorization = 'Bearer invalid-token'; + const middleware = requireAuth(); + + middleware(req, res, next); + + expect(res.status).toHaveBeenCalledWith(401); + expect(res.json).toHaveBeenCalledWith({ error: 'Invalid or expired token' }); + }); + + it('should return 500 when SESSION_SECRET is not set for JWT auth', () => { + vi.stubEnv('SESSION_SECRET', ''); + req.headers.authorization = 'Bearer some-token'; + const middleware = requireAuth(); + + middleware(req, res, next); + + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith({ error: 'Session not configured' }); + }); + + it('should reject when x-api-secret is invalid even if valid JWT is present', () => { + vi.stubEnv('BOT_API_SECRET', 'test-secret'); + vi.stubEnv('SESSION_SECRET', 'jwt-test-secret'); + req.headers['x-api-secret'] = 'wrong-secret'; + sessionStore.set('456', 'discord-access-token'); + const token = jwt.sign({ userId: '456' }, 'jwt-test-secret', { algorithm: 'HS256' }); + req.headers.authorization = `Bearer ${token}`; + const middleware = requireAuth(); + + middleware(req, res, next); + + // Wrong API secret should reject immediately — no JWT fallback + expect(res.status).toHaveBeenCalledWith(401); + expect(res.json).toHaveBeenCalledWith({ error: 'Invalid API secret' }); + expect(next).not.toHaveBeenCalled(); + }); }); diff --git a/tests/api/middleware/oauth.test.js b/tests/api/middleware/oauth.test.js new file mode 100644 index 000000000..dab343cfd --- /dev/null +++ b/tests/api/middleware/oauth.test.js @@ -0,0 +1,136 @@ +import jwt from 'jsonwebtoken'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../../src/logger.js', () => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), +})); + +import { requireOAuth } from '../../../src/api/middleware/oauth.js'; +import { _resetSecretCache } from '../../../src/api/middleware/verifyJwt.js'; +import { sessionStore } from '../../../src/api/utils/sessionStore.js'; + +describe('requireOAuth middleware', () => { + let req; + let res; + let next; + + beforeEach(() => { + req = { headers: {}, ip: '127.0.0.1', path: '/test' }; + res = { + status: vi.fn().mockReturnThis(), + json: vi.fn().mockReturnThis(), + }; + next = vi.fn(); + }); + + afterEach(() => { + sessionStore.clear(); + _resetSecretCache(); + vi.clearAllMocks(); + vi.unstubAllEnvs(); + }); + + it('should return 401 when no Authorization header', () => { + const middleware = requireOAuth(); + + middleware(req, res, next); + + expect(res.status).toHaveBeenCalledWith(401); + expect(res.json).toHaveBeenCalledWith({ error: 'No token provided' }); + expect(next).not.toHaveBeenCalled(); + }); + + it('should return 401 when Authorization header does not start with Bearer', () => { + req.headers.authorization = 'Basic abc123'; + const middleware = requireOAuth(); + + middleware(req, res, next); + + expect(res.status).toHaveBeenCalledWith(401); + expect(res.json).toHaveBeenCalledWith({ error: 'No token provided' }); + }); + + it('should still return No token provided even if x-api-secret header is present', () => { + req.headers['x-api-secret'] = 'test-secret'; + const middleware = requireOAuth(); + + middleware(req, res, next); + + expect(res.status).toHaveBeenCalledWith(401); + expect(res.json).toHaveBeenCalledWith({ error: 'No token provided' }); + expect(next).not.toHaveBeenCalled(); + }); + + it('should return 500 when SESSION_SECRET is not set', () => { + vi.stubEnv('SESSION_SECRET', ''); + req.headers.authorization = 'Bearer some-token'; + const middleware = requireOAuth(); + + middleware(req, res, next); + + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith({ error: 'Session not configured' }); + }); + + it('should return 401 for invalid JWT', () => { + vi.stubEnv('SESSION_SECRET', 'test-secret'); + req.headers.authorization = 'Bearer invalid-token'; + const middleware = requireOAuth(); + + middleware(req, res, next); + + expect(res.status).toHaveBeenCalledWith(401); + expect(res.json).toHaveBeenCalledWith({ error: 'Invalid or expired token' }); + }); + + it('should attach decoded user and call next() for valid JWT', () => { + vi.stubEnv('SESSION_SECRET', 'test-secret'); + sessionStore.set('123', 'discord-access-token'); + const token = jwt.sign({ userId: '123', username: 'testuser' }, 'test-secret', { + algorithm: 'HS256', + }); + req.headers.authorization = `Bearer ${token}`; + const middleware = requireOAuth(); + + middleware(req, res, next); + + expect(next).toHaveBeenCalled(); + expect(req.user).toBeDefined(); + expect(req.user.userId).toBe('123'); + expect(req.user.username).toBe('testuser'); + expect(req.authMethod).toBe('oauth'); + }); + + it('should return 401 when JWT is valid but server-side session is missing', () => { + vi.stubEnv('SESSION_SECRET', 'test-secret'); + // Sign a valid JWT but do NOT populate sessionStore + const token = jwt.sign({ userId: '999', username: 'nosession' }, 'test-secret', { + algorithm: 'HS256', + }); + req.headers.authorization = `Bearer ${token}`; + const middleware = requireOAuth(); + + middleware(req, res, next); + + expect(res.status).toHaveBeenCalledWith(401); + expect(res.json).toHaveBeenCalledWith({ error: 'Session expired or revoked' }); + expect(next).not.toHaveBeenCalled(); + }); + + it('should return 401 for expired JWT', () => { + vi.stubEnv('SESSION_SECRET', 'test-secret'); + const token = jwt.sign({ userId: '123' }, 'test-secret', { + algorithm: 'HS256', + expiresIn: '-1s', + }); + req.headers.authorization = `Bearer ${token}`; + const middleware = requireOAuth(); + + middleware(req, res, next); + + expect(res.status).toHaveBeenCalledWith(401); + expect(res.json).toHaveBeenCalledWith({ error: 'Invalid or expired token' }); + }); +}); diff --git a/tests/api/routes/auth.test.js b/tests/api/routes/auth.test.js new file mode 100644 index 000000000..c01515105 --- /dev/null +++ b/tests/api/routes/auth.test.js @@ -0,0 +1,397 @@ +import jwt from 'jsonwebtoken'; +import request from 'supertest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../../src/logger.js', () => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), +})); + +import { _resetSecretCache } from '../../../src/api/middleware/verifyJwt.js'; +import { _seedOAuthState } from '../../../src/api/routes/auth.js'; +import { createApp } from '../../../src/api/server.js'; +import { guildCache } from '../../../src/api/utils/discordApi.js'; +import { sessionStore } from '../../../src/api/utils/sessionStore.js'; +import { error as logError } from '../../../src/logger.js'; + +describe('auth routes', () => { + let app; + + beforeEach(() => { + vi.stubEnv('BOT_API_SECRET', 'test-secret'); + + const client = { + guilds: { cache: new Map() }, + ws: { status: 0, ping: 42 }, + user: { tag: 'Bot#1234' }, + }; + + app = createApp(client, null); + }); + + afterEach(() => { + sessionStore.clear(); + guildCache.clear(); + _resetSecretCache(); + vi.clearAllMocks(); + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + }); + + describe('GET /api/v1/auth/discord', () => { + it('should redirect to Discord OAuth2 URL when configured', async () => { + vi.stubEnv('DISCORD_CLIENT_ID', 'client-id-123'); + vi.stubEnv('DISCORD_REDIRECT_URI', 'http://localhost:3000/callback'); + + const res = await request(app).get('/api/v1/auth/discord'); + + expect(res.status).toBe(302); + expect(res.headers.location).toContain('discord.com/oauth2/authorize'); + expect(res.headers.location).toContain('client_id=client-id-123'); + expect(res.headers.location).toContain('scope=identify+guilds'); + expect(res.headers.location).toContain('state='); + }); + + it('should return 500 when DISCORD_CLIENT_ID is not set', async () => { + vi.stubEnv('DISCORD_CLIENT_ID', ''); + vi.stubEnv('DISCORD_REDIRECT_URI', 'http://localhost:3000/callback'); + + const res = await request(app).get('/api/v1/auth/discord'); + + expect(res.status).toBe(500); + expect(res.body.error).toBe('OAuth2 not configured'); + expect(logError).toHaveBeenCalledWith('OAuth2 not configured for /discord', { + hasClientId: false, + hasRedirectUri: true, + }); + }); + + it('should return 500 when DISCORD_REDIRECT_URI is not set', async () => { + vi.stubEnv('DISCORD_CLIENT_ID', 'client-id-123'); + vi.stubEnv('DISCORD_REDIRECT_URI', ''); + + const res = await request(app).get('/api/v1/auth/discord'); + + expect(res.status).toBe(500); + expect(res.body.error).toBe('OAuth2 not configured'); + expect(logError).toHaveBeenCalledWith('OAuth2 not configured for /discord', { + hasClientId: true, + hasRedirectUri: false, + }); + }); + }); + + describe('GET /api/v1/auth/discord/callback', () => { + it('should return 400 when code is missing', async () => { + const res = await request(app).get('/api/v1/auth/discord/callback'); + + expect(res.status).toBe(400); + expect(res.body.error).toBe('Missing authorization code'); + }); + + it('should return 403 when state is missing or invalid', async () => { + const res = await request(app).get('/api/v1/auth/discord/callback?code=test-code'); + + expect(res.status).toBe(403); + expect(res.body.error).toContain('Invalid or expired OAuth state'); + }); + + it('should exchange code for token and redirect with JWT on success', async () => { + vi.stubEnv('DISCORD_CLIENT_ID', 'client-id-123'); + vi.stubEnv('DISCORD_CLIENT_SECRET', 'client-secret'); + vi.stubEnv('DISCORD_REDIRECT_URI', 'http://localhost:3001/callback'); + vi.stubEnv('SESSION_SECRET', 'test-session-secret'); + vi.stubEnv('DASHBOARD_URL', 'http://localhost:3000'); + + // Seed a valid OAuth state + const state = 'test-state-abc'; + _seedOAuthState(state); + + // Mock token exchange and user info fetch + vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ access_token: 'discord-access-token' }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + id: '999', + username: 'newuser', + avatar: 'avatar123', + }), + }); + + const res = await request(app).get( + `/api/v1/auth/discord/callback?code=valid-code&state=${state}`, + ); + + expect(res.status).toBe(302); + expect(res.headers.location).toMatch(/^http:\/\/localhost:3000#token=.+/); + + // Verify session was stored server-side + expect(sessionStore.get('999')).toBe('discord-access-token'); + + // Verify the JWT in the redirect contains user info + const token = res.headers.location.split('#token=')[1]; + const decoded = jwt.verify(token, 'test-session-secret', { algorithms: ['HS256'] }); + expect(decoded.userId).toBe('999'); + expect(decoded.username).toBe('newuser'); + }); + + it('should return 401 when token exchange response is non-OK', async () => { + vi.stubEnv('DISCORD_CLIENT_ID', 'client-id-123'); + vi.stubEnv('DISCORD_CLIENT_SECRET', 'client-secret'); + vi.stubEnv('DISCORD_REDIRECT_URI', 'http://localhost:3001/callback'); + vi.stubEnv('SESSION_SECRET', 'test-session-secret'); + + const state = 'test-state-token-non-ok'; + _seedOAuthState(state); + + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce({ + ok: false, + status: 400, + }); + + const res = await request(app).get( + `/api/v1/auth/discord/callback?code=valid-code&state=${state}`, + ); + + expect(res.status).toBe(401); + expect(res.body.error).toBe('Failed to exchange authorization code'); + }); + + it('should return 401 when user info response is non-OK', async () => { + vi.stubEnv('DISCORD_CLIENT_ID', 'client-id-123'); + vi.stubEnv('DISCORD_CLIENT_SECRET', 'client-secret'); + vi.stubEnv('DISCORD_REDIRECT_URI', 'http://localhost:3001/callback'); + vi.stubEnv('SESSION_SECRET', 'test-session-secret'); + + const state = 'test-state-user-non-ok'; + _seedOAuthState(state); + + vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ access_token: 'discord-access-token' }), + }) + .mockResolvedValueOnce({ + ok: false, + status: 401, + }); + + const res = await request(app).get( + `/api/v1/auth/discord/callback?code=valid-code&state=${state}`, + ); + + expect(res.status).toBe(401); + expect(res.body.error).toBe('Failed to fetch user info'); + }); + + it('should return 502 when token exchange payload has missing or invalid access_token', async () => { + vi.stubEnv('DISCORD_CLIENT_ID', 'client-id-123'); + vi.stubEnv('DISCORD_CLIENT_SECRET', 'client-secret'); + vi.stubEnv('DISCORD_REDIRECT_URI', 'http://localhost:3001/callback'); + vi.stubEnv('SESSION_SECRET', 'test-session-secret'); + + const state = 'test-state-invalid-access-token'; + _seedOAuthState(state); + + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce({ + ok: true, + json: async () => ({ access_token: '' }), + }); + + const res = await request(app).get( + `/api/v1/auth/discord/callback?code=valid-code&state=${state}`, + ); + + expect(res.status).toBe(502); + expect(res.body.error).toBe('Invalid response from Discord'); + }); + + it('should return 502 when user info payload has missing or invalid user.id', async () => { + vi.stubEnv('DISCORD_CLIENT_ID', 'client-id-123'); + vi.stubEnv('DISCORD_CLIENT_SECRET', 'client-secret'); + vi.stubEnv('DISCORD_REDIRECT_URI', 'http://localhost:3001/callback'); + vi.stubEnv('SESSION_SECRET', 'test-session-secret'); + + const state = 'test-state-invalid-user-id'; + _seedOAuthState(state); + + vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ access_token: 'discord-access-token' }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ id: '' }), + }); + + const res = await request(app).get( + `/api/v1/auth/discord/callback?code=valid-code&state=${state}`, + ); + + expect(res.status).toBe(502); + expect(res.body.error).toBe('Invalid response from Discord'); + }); + + it('should return 500 and log when OAuth callback config is missing', async () => { + vi.stubEnv('DISCORD_CLIENT_ID', 'client-id-123'); + vi.stubEnv('DISCORD_CLIENT_SECRET', ''); + vi.stubEnv('DISCORD_REDIRECT_URI', 'http://localhost:3001/callback'); + vi.stubEnv('SESSION_SECRET', 'test-session-secret'); + + const state = 'test-state-missing-config'; + _seedOAuthState(state); + + const res = await request(app).get( + `/api/v1/auth/discord/callback?code=valid-code&state=${state}`, + ); + + expect(res.status).toBe(500); + expect(res.body.error).toBe('OAuth2 not configured'); + expect(logError).toHaveBeenCalledWith('OAuth2 not configured for /discord/callback', { + hasClientId: true, + hasClientSecret: false, + hasRedirectUri: true, + hasSessionSecret: true, + }); + }); + }); + + describe('GET /api/v1/auth/me', () => { + it('should return user info from valid JWT', async () => { + vi.stubEnv('SESSION_SECRET', 'test-session-secret'); + + const mockGuilds = [{ id: 'g1', name: 'Test Guild', permissions: '8' }]; + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce({ + ok: true, + json: async () => mockGuilds, + }); + + // Store access token server-side (no longer in JWT) + sessionStore.set('123', 'discord-access-token'); + + const token = jwt.sign( + { + userId: '123', + username: 'testuser', + avatar: 'abc123', + }, + 'test-session-secret', + { algorithm: 'HS256' }, + ); + + const res = await request(app).get('/api/v1/auth/me').set('Authorization', `Bearer ${token}`); + + expect(res.status).toBe(200); + expect(res.body.userId).toBe('123'); + expect(res.body.username).toBe('testuser'); + expect(res.body.discriminator).toBeUndefined(); + expect(res.body.guilds).toHaveLength(1); + }); + + it('should return user info without guilds when fetch fails', async () => { + vi.stubEnv('SESSION_SECRET', 'test-session-secret'); + + vi.spyOn(globalThis, 'fetch').mockRejectedValueOnce(new Error('Network error')); + + // Store access token server-side (no longer in JWT) + sessionStore.set('123', 'discord-access-token'); + + const token = jwt.sign( + { + userId: '123', + username: 'testuser', + avatar: 'abc123', + }, + 'test-session-secret', + { algorithm: 'HS256' }, + ); + + const res = await request(app).get('/api/v1/auth/me').set('Authorization', `Bearer ${token}`); + + expect(res.status).toBe(200); + expect(res.body.userId).toBe('123'); + expect(res.body.guilds).toHaveLength(0); + }); + + it('should return 401 when no token provided', async () => { + const res = await request(app).get('/api/v1/auth/me'); + + expect(res.status).toBe(401); + expect(res.body.error).toBe('No token provided'); + }); + + it('should return 401 for invalid token', async () => { + vi.stubEnv('SESSION_SECRET', 'test-session-secret'); + + const res = await request(app) + .get('/api/v1/auth/me') + .set('Authorization', 'Bearer invalid-token'); + + expect(res.status).toBe(401); + expect(res.body.error).toBe('Invalid or expired token'); + }); + + it('should return 500 when SESSION_SECRET is not set', async () => { + vi.stubEnv('SESSION_SECRET', ''); + + const res = await request(app) + .get('/api/v1/auth/me') + .set('Authorization', 'Bearer some-token'); + + expect(res.status).toBe(500); + expect(res.body.error).toBe('Session not configured'); + }); + + it('should return 401 when session has been revoked', async () => { + vi.stubEnv('SESSION_SECRET', 'test-session-secret'); + + // Sign a valid JWT but do NOT populate sessionStore + const token = jwt.sign( + { + userId: '456', + username: 'revokeduser', + avatar: 'def456', + }, + 'test-session-secret', + { algorithm: 'HS256' }, + ); + + const res = await request(app).get('/api/v1/auth/me').set('Authorization', `Bearer ${token}`); + + expect(res.status).toBe(401); + expect(res.body.error).toBe('Session expired or revoked'); + }); + }); + + describe('POST /api/v1/auth/logout', () => { + it('should delete session and return success when authenticated', async () => { + vi.stubEnv('SESSION_SECRET', 'test-session-secret'); + + sessionStore.set('123', 'discord-access-token'); + const token = jwt.sign({ userId: '123', username: 'testuser' }, 'test-session-secret', { + algorithm: 'HS256', + }); + + const res = await request(app) + .post('/api/v1/auth/logout') + .set('Authorization', `Bearer ${token}`); + + expect(res.status).toBe(200); + expect(res.body.message).toBe('Logged out successfully'); + expect(sessionStore.has('123')).toBe(false); + }); + + it('should return 401 when no token provided', async () => { + const res = await request(app).post('/api/v1/auth/logout'); + + expect(res.status).toBe(401); + expect(res.body.error).toBe('No token provided'); + }); + }); +}); diff --git a/tests/api/routes/guilds.test.js b/tests/api/routes/guilds.test.js index 989916b23..ba1758f6a 100644 --- a/tests/api/routes/guilds.test.js +++ b/tests/api/routes/guilds.test.js @@ -1,3 +1,4 @@ +import jwt from 'jsonwebtoken'; import request from 'supertest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -13,6 +14,7 @@ vi.mock('../../../src/modules/config.js', () => ({ welcome: { enabled: true }, spam: { enabled: true }, moderation: { enabled: true }, + permissions: { botOwners: [] }, database: { host: 'secret-host' }, token: 'secret-token', }), @@ -23,7 +25,10 @@ vi.mock('../../../src/utils/safeSend.js', () => ({ safeSend: vi.fn().mockResolvedValue({ id: 'msg1', content: 'Hello!' }), })); +import { _resetSecretCache } from '../../../src/api/middleware/verifyJwt.js'; import { createApp } from '../../../src/api/server.js'; +import { guildCache } from '../../../src/api/utils/discordApi.js'; +import { sessionStore } from '../../../src/api/utils/sessionStore.js'; import { getConfig, setConfigValue } from '../../../src/modules/config.js'; import { safeSend } from '../../../src/utils/safeSend.js'; @@ -88,10 +93,36 @@ describe('guilds routes', () => { }); afterEach(() => { + sessionStore.clear(); + guildCache.clear(); + _resetSecretCache(); vi.clearAllMocks(); vi.unstubAllEnvs(); + vi.restoreAllMocks(); }); + /** + * Helper: create a JWT and populate the server-side session store + */ + function createOAuthToken(secret = 'jwt-test-secret', userId = '123') { + sessionStore.set(userId, 'discord-access-token'); + return jwt.sign( + { + userId, + username: 'testuser', + }, + secret, + { algorithm: 'HS256' }, + ); + } + + function mockFetchGuilds(guilds) { + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce({ + ok: true, + json: async () => guilds, + }); + } + describe('authentication', () => { it('should return 401 without x-api-secret header', async () => { const res = await request(app).get('/api/v1/guilds/guild1'); @@ -104,6 +135,35 @@ describe('guilds routes', () => { const res = await request(app).get('/api/v1/guilds/guild1').set('x-api-secret', 'wrong'); expect(res.status).toBe(401); + expect(res.body.error).toBe('Invalid API secret'); + }); + + it('should authenticate with valid JWT Bearer token', async () => { + vi.stubEnv('SESSION_SECRET', 'jwt-test-secret'); + const token = createOAuthToken(); + mockFetchGuilds([{ id: 'guild1', name: 'Test Server', permissions: String(0x8) }]); + + const res = await request(app) + .get('/api/v1/guilds/guild1') + .set('Authorization', `Bearer ${token}`); + + expect(res.status).toBe(200); + expect(res.body.id).toBe('guild1'); + }); + + it('should return 401 when session has been revoked', async () => { + vi.stubEnv('SESSION_SECRET', 'jwt-test-secret'); + // Sign a valid JWT but do NOT populate sessionStore + const token = jwt.sign({ userId: '789', username: 'revokeduser' }, 'jwt-test-secret', { + algorithm: 'HS256', + }); + + const res = await request(app) + .get('/api/v1/guilds/guild1') + .set('Authorization', `Bearer ${token}`); + + expect(res.status).toBe(401); + expect(res.body.error).toBe('Session expired or revoked'); }); }); @@ -116,6 +176,114 @@ describe('guilds routes', () => { }); }); + describe('GET /', () => { + it('should return all guilds for api-secret auth', async () => { + const res = await request(app).get('/api/v1/guilds').set('x-api-secret', SECRET); + + expect(res.status).toBe(200); + expect(res.body).toHaveLength(1); + expect(res.body[0].id).toBe('guild1'); + expect(res.body[0].name).toBe('Test Server'); + expect(res.body[0].memberCount).toBe(100); + }); + + it('should return OAuth guilds with access metadata', async () => { + vi.stubEnv('SESSION_SECRET', 'jwt-test-secret'); + const token = createOAuthToken(); + mockFetchGuilds([ + { id: 'guild1', name: 'Test Server', permissions: '8' }, + { id: 'guild-not-in-bot', name: 'Other Server', permissions: '8' }, + ]); + + const res = await request(app).get('/api/v1/guilds').set('Authorization', `Bearer ${token}`); + + expect(res.status).toBe(200); + // Only guild1 (bot is in it AND user has admin), not guild-not-in-bot + expect(res.body).toHaveLength(1); + expect(res.body[0].id).toBe('guild1'); + expect(res.body[0].access).toBe('admin'); + }); + + it('should include guilds where OAuth user has MANAGE_GUILD', async () => { + vi.stubEnv('SESSION_SECRET', 'jwt-test-secret'); + const token = createOAuthToken(); + // 0x20 = MANAGE_GUILD but not ADMINISTRATOR + mockFetchGuilds([{ id: 'guild1', name: 'Test Server', permissions: '32' }]); + + const res = await request(app).get('/api/v1/guilds').set('Authorization', `Bearer ${token}`); + + expect(res.status).toBe(200); + expect(res.body).toHaveLength(1); + expect(res.body[0].id).toBe('guild1'); + expect(res.body[0].access).toBe('moderator'); + }); + + it('should include admin and moderator access values when both are present', async () => { + vi.stubEnv('SESSION_SECRET', 'jwt-test-secret'); + const token = createOAuthToken(); + const mockGuild2 = { + ...mockGuild, + id: 'guild2', + name: 'Second Server', + memberCount: 50, + }; + const client = { + guilds: { + cache: new Map([ + ['guild1', mockGuild], + ['guild2', mockGuild2], + ]), + }, + ws: { status: 0, ping: 42 }, + user: { tag: 'Bot#1234' }, + }; + app = createApp(client, mockPool); + mockFetchGuilds([ + { id: 'guild1', name: 'Test Server', permissions: String(0x8) }, + { id: 'guild2', name: 'Second Server', permissions: String(0x20) }, + ]); + + const res = await request(app).get('/api/v1/guilds').set('Authorization', `Bearer ${token}`); + + expect(res.status).toBe(200); + expect(res.body).toHaveLength(2); + expect(res.body.find((g) => g.id === 'guild1')?.access).toBe('admin'); + expect(res.body.find((g) => g.id === 'guild2')?.access).toBe('moderator'); + }); + + it('should allow bot-owner OAuth users to list all bot guilds without Discord fetch', async () => { + vi.stubEnv('SESSION_SECRET', 'jwt-test-secret'); + getConfig.mockReturnValueOnce({ + ai: { model: 'claude-3' }, + welcome: { enabled: true }, + spam: { enabled: true }, + moderation: { enabled: true }, + permissions: { botOwners: ['owner-1'] }, + }); + const token = createOAuthToken('jwt-test-secret', 'owner-1'); + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + + const res = await request(app).get('/api/v1/guilds').set('Authorization', `Bearer ${token}`); + + expect(res.status).toBe(200); + expect(res.body).toHaveLength(1); + expect(res.body[0].id).toBe('guild1'); + expect(res.body[0].access).toBe('bot-owner'); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('should exclude guilds where OAuth user has no admin permissions', async () => { + vi.stubEnv('SESSION_SECRET', 'jwt-test-secret'); + const token = createOAuthToken(); + mockFetchGuilds([{ id: 'guild1', name: 'Test Server', permissions: '0' }]); + + const res = await request(app).get('/api/v1/guilds').set('Authorization', `Bearer ${token}`); + + expect(res.status).toBe(200); + expect(res.body).toHaveLength(0); + }); + }); + describe('GET /:id', () => { it('should return guild info', async () => { const res = await request(app).get('/api/v1/guilds/guild1').set('x-api-secret', SECRET); @@ -130,6 +298,88 @@ describe('guilds routes', () => { }); }); + describe('guild admin verification (OAuth)', () => { + it('should allow api-secret users to access admin endpoints', async () => { + const res = await request(app) + .get('/api/v1/guilds/guild1/config') + .set('x-api-secret', SECRET); + + expect(res.status).toBe(200); + }); + + it('should allow OAuth users with admin permission on guild', async () => { + vi.stubEnv('SESSION_SECRET', 'jwt-test-secret'); + const token = createOAuthToken(); + mockFetchGuilds([{ id: 'guild1', name: 'Test', permissions: '8' }]); + + const res = await request(app) + .get('/api/v1/guilds/guild1/config') + .set('Authorization', `Bearer ${token}`); + + expect(res.status).toBe(200); + }); + + it('should deny OAuth users with only MANAGE_GUILD on admin endpoints', async () => { + vi.stubEnv('SESSION_SECRET', 'jwt-test-secret'); + const token = createOAuthToken(); + // 0x20 = MANAGE_GUILD but not ADMINISTRATOR — admin requires ADMINISTRATOR only + mockFetchGuilds([{ id: 'guild1', name: 'Test', permissions: '32' }]); + + const res = await request(app) + .get('/api/v1/guilds/guild1/config') + .set('Authorization', `Bearer ${token}`); + + expect(res.status).toBe(403); + expect(res.body.error).toContain('admin access'); + }); + + it('should deny OAuth users without admin or manage-guild permission', async () => { + vi.stubEnv('SESSION_SECRET', 'jwt-test-secret'); + const token = createOAuthToken(); + mockFetchGuilds([{ id: 'guild1', name: 'Test', permissions: '0' }]); + + const res = await request(app) + .get('/api/v1/guilds/guild1/config') + .set('Authorization', `Bearer ${token}`); + + expect(res.status).toBe(403); + expect(res.body.error).toContain('admin access'); + }); + + it('should deny OAuth users not in the guild', async () => { + vi.stubEnv('SESSION_SECRET', 'jwt-test-secret'); + const token = createOAuthToken(); + mockFetchGuilds([{ id: 'other-guild', name: 'Other', permissions: '8' }]); + + const res = await request(app) + .get('/api/v1/guilds/guild1/config') + .set('Authorization', `Bearer ${token}`); + + expect(res.status).toBe(403); + expect(res.body.error).toContain('admin access'); + }); + + it('should allow bot-owner OAuth users to access admin endpoints without Discord fetch', async () => { + vi.stubEnv('SESSION_SECRET', 'jwt-test-secret'); + getConfig.mockReturnValueOnce({ + ai: { model: 'claude-3' }, + welcome: { enabled: true }, + spam: { enabled: true }, + moderation: { enabled: true }, + permissions: { botOwners: ['owner-admin'] }, + }); + const token = createOAuthToken('jwt-test-secret', 'owner-admin'); + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + + const res = await request(app) + .get('/api/v1/guilds/guild1/config') + .set('Authorization', `Bearer ${token}`); + + expect(res.status).toBe(200); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + }); + describe('GET /:id/config', () => { it('should return only safe config keys', async () => { const res = await request(app) @@ -383,6 +633,58 @@ describe('guilds routes', () => { 10, ]); }); + + it('should allow OAuth users with MANAGE_GUILD permission', async () => { + vi.stubEnv('SESSION_SECRET', 'jwt-test-secret'); + const token = createOAuthToken(); + mockFetchGuilds([{ id: 'guild1', name: 'Test', permissions: String(0x20) }]); + mockPool.query.mockResolvedValueOnce({ rows: [{ count: 1 }] }).mockResolvedValueOnce({ + rows: [{ id: 1, case_number: 1, action: 'warn', guild_id: 'guild1' }], + }); + + const res = await request(app) + .get('/api/v1/guilds/guild1/moderation') + .set('Authorization', `Bearer ${token}`); + + expect(res.status).toBe(200); + expect(res.body.total).toBe(1); + }); + + it('should allow bot-owner OAuth users on moderator endpoints without Discord fetch', async () => { + vi.stubEnv('SESSION_SECRET', 'jwt-test-secret'); + getConfig.mockReturnValueOnce({ + ai: { model: 'claude-3' }, + welcome: { enabled: true }, + spam: { enabled: true }, + moderation: { enabled: true }, + permissions: { botOwners: ['owner-mod'] }, + }); + const token = createOAuthToken('jwt-test-secret', 'owner-mod'); + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + mockPool.query.mockResolvedValueOnce({ rows: [{ count: 1 }] }).mockResolvedValueOnce({ + rows: [{ id: 1, case_number: 1, action: 'warn', guild_id: 'guild1' }], + }); + + const res = await request(app) + .get('/api/v1/guilds/guild1/moderation') + .set('Authorization', `Bearer ${token}`); + + expect(res.status).toBe(200); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('should deny OAuth users without moderator permissions', async () => { + vi.stubEnv('SESSION_SECRET', 'jwt-test-secret'); + const token = createOAuthToken(); + mockFetchGuilds([{ id: 'guild1', name: 'Test', permissions: '0' }]); + + const res = await request(app) + .get('/api/v1/guilds/guild1/moderation') + .set('Authorization', `Bearer ${token}`); + + expect(res.status).toBe(403); + expect(res.body.error).toContain('moderator access'); + }); }); describe('POST /:id/actions', () => { diff --git a/tests/api/utils/discordApi.test.js b/tests/api/utils/discordApi.test.js new file mode 100644 index 000000000..cc259881c --- /dev/null +++ b/tests/api/utils/discordApi.test.js @@ -0,0 +1,29 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../../src/logger.js', () => ({ + error: vi.fn(), +})); + +import { fetchUserGuilds, guildCache } from '../../../src/api/utils/discordApi.js'; + +describe('discordApi utils', () => { + afterEach(() => { + guildCache.clear(); + vi.restoreAllMocks(); + }); + + it('should evict until cache size is capped after burst inserts', async () => { + for (let i = 0; i < 10005; i += 1) { + guildCache.set(`user-${i}`, { guilds: [], expiresAt: Date.now() + 60_000 }); + } + + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce({ + ok: true, + json: async () => [], + }); + + await fetchUserGuilds('new-user', 'token'); + + expect(guildCache.size).toBe(10000); + }); +}); diff --git a/tests/commands/config.test.js b/tests/commands/config.test.js index eecc2b8fd..e0a692faa 100644 --- a/tests/commands/config.test.js +++ b/tests/commands/config.test.js @@ -6,13 +6,20 @@ vi.mock('../../src/modules/config.js', () => ({ ai: { enabled: true, model: 'test-model', maxTokens: 1024 }, welcome: { enabled: false, channelId: '' }, moderation: { enabled: false }, + permissions: { enabled: true, adminRoleId: null, usePermissions: true }, }), setConfigValue: vi.fn().mockResolvedValue({ enabled: true, model: 'new-model' }), resetConfig: vi.fn().mockResolvedValue({}), })); +vi.mock('../../src/utils/permissions.js', () => ({ + hasPermission: vi.fn().mockReturnValue(true), + getPermissionError: vi.fn().mockReturnValue("❌ You don't have permission to use `/config`."), +})); + import { autocomplete, data, execute } from '../../src/commands/config.js'; import { getConfig, resetConfig, setConfigValue } from '../../src/modules/config.js'; +import { hasPermission } from '../../src/utils/permissions.js'; describe('config command', () => { afterEach(() => { @@ -28,6 +35,77 @@ describe('config command', () => { expect(mod.adminOnly).toBe(true); }); + it('should deny permission when hasPermission fails', async () => { + hasPermission.mockReturnValueOnce(false); + + const mockReply = vi.fn(); + const interaction = { + member: {}, + options: { + getSubcommand: vi.fn().mockReturnValue('view'), + getString: vi.fn().mockReturnValue(null), + }, + reply: mockReply, + }; + + await execute(interaction); + expect(hasPermission).toHaveBeenCalledWith( + interaction.member, + 'config', + expect.objectContaining({ permissions: expect.any(Object) }), + ); + expect(mockReply).toHaveBeenCalledWith( + expect.objectContaining({ + content: expect.stringContaining("don't have permission"), + ephemeral: true, + }), + ); + }); + + it('should allow command when permissions.enabled is false', async () => { + getConfig.mockReturnValueOnce({ + ai: { enabled: true }, + permissions: { enabled: false, usePermissions: true }, + }); + hasPermission.mockReturnValueOnce(true); + + const interaction = { + member: {}, + options: { + getSubcommand: vi.fn().mockReturnValue('view'), + getString: vi.fn().mockReturnValue(null), + }, + reply: vi.fn(), + }; + + await execute(interaction); + expect(interaction.reply).toHaveBeenCalledWith( + expect.objectContaining({ embeds: expect.any(Array), ephemeral: true }), + ); + }); + + it('should allow command when permissions.usePermissions is false', async () => { + getConfig.mockReturnValueOnce({ + ai: { enabled: true }, + permissions: { enabled: true, usePermissions: false }, + }); + hasPermission.mockReturnValueOnce(true); + + const interaction = { + member: {}, + options: { + getSubcommand: vi.fn().mockReturnValue('view'), + getString: vi.fn().mockReturnValue(null), + }, + reply: vi.fn(), + }; + + await execute(interaction); + expect(interaction.reply).toHaveBeenCalledWith( + expect.objectContaining({ embeds: expect.any(Array), ephemeral: true }), + ); + }); + describe('autocomplete', () => { it('should autocomplete section names', async () => { const mockRespond = vi.fn(); @@ -119,7 +197,7 @@ describe('config command', () => { // Each section generates ~1023 chars in the embed (JSON truncated to 1000 + field name) // Need 6+ sections to push past the 5800-char truncation threshold const largeValue = 'x'.repeat(1500); - getConfig.mockReturnValueOnce({ + const largeConfig = { section1: { data: largeValue }, section2: { data: largeValue }, section3: { data: largeValue }, @@ -127,7 +205,11 @@ describe('config command', () => { section5: { data: largeValue }, section6: { data: largeValue }, section7: { data: largeValue }, - }); + }; + // First getConfig call is in execute() for permission check; second is in handleView() + getConfig + .mockReturnValueOnce({ permissions: { enabled: true, usePermissions: true } }) + .mockReturnValueOnce(largeConfig); const mockReply = vi.fn(); const interaction = { options: { @@ -149,9 +231,12 @@ describe('config command', () => { }); it('should handle getConfig throwing an error', async () => { - getConfig.mockImplementationOnce(() => { - throw new Error('config error'); - }); + // First getConfig call is in execute() for permission check; second throws in handleView() + getConfig + .mockReturnValueOnce({ permissions: { enabled: true, usePermissions: true } }) + .mockImplementationOnce(() => { + throw new Error('config error'); + }); const mockReply = vi.fn(); const interaction = { options: { @@ -175,9 +260,12 @@ describe('config command', () => { delete process.env.NODE_ENV; try { - getConfig.mockImplementationOnce(() => { - throw new Error('pg: connection refused at 10.0.0.5:5432'); - }); + // First getConfig call is in execute() for permission check; second throws in handleView() + getConfig + .mockReturnValueOnce({ permissions: { enabled: true, usePermissions: true } }) + .mockImplementationOnce(() => { + throw new Error('pg: connection refused at 10.0.0.5:5432'); + }); const mockReply = vi.fn(); const interaction = { options: { diff --git a/tests/commands/modlog.test.js b/tests/commands/modlog.test.js index 882533f47..3f5d462cb 100644 --- a/tests/commands/modlog.test.js +++ b/tests/commands/modlog.test.js @@ -29,9 +29,14 @@ vi.mock('../../src/logger.js', () => ({ error: vi.fn(), warn: vi.fn(), })); +vi.mock('../../src/utils/permissions.js', () => ({ + hasPermission: vi.fn().mockReturnValue(true), + getPermissionError: vi.fn().mockReturnValue("❌ You don't have permission to use `/modlog`."), +})); import { adminOnly, data, execute } from '../../src/commands/modlog.js'; import { getConfig, setConfigValue } from '../../src/modules/config.js'; +import { hasPermission } from '../../src/utils/permissions.js'; function createInteraction(subcommand) { const collectHandlers = {}; @@ -39,6 +44,7 @@ function createInteraction(subcommand) { options: { getSubcommand: vi.fn().mockReturnValue(subcommand), }, + member: {}, user: { id: 'mod1', tag: 'Mod#0001' }, reply: vi.fn().mockResolvedValue({ createMessageComponentCollector: vi.fn().mockReturnValue({ @@ -67,6 +73,55 @@ describe('modlog command', () => { expect(adminOnly).toBe(true); }); + it('should deny permission when hasPermission fails', async () => { + hasPermission.mockReturnValueOnce(false); + + const interaction = createInteraction('view'); + await execute(interaction); + + expect(hasPermission).toHaveBeenCalledWith( + interaction.member, + 'modlog', + expect.objectContaining({ moderation: expect.any(Object) }), + ); + expect(interaction.reply).toHaveBeenCalledWith( + expect.objectContaining({ + content: expect.stringContaining("don't have permission"), + ephemeral: true, + }), + ); + }); + + it('should allow command when permissions.enabled is false', async () => { + getConfig.mockReturnValueOnce({ + moderation: { logging: { channels: {} } }, + permissions: { enabled: false, usePermissions: true }, + }); + hasPermission.mockReturnValueOnce(true); + + const interaction = createInteraction('view'); + await execute(interaction); + + expect(interaction.reply).toHaveBeenCalledWith( + expect.objectContaining({ embeds: expect.any(Array), ephemeral: true }), + ); + }); + + it('should allow command when permissions.usePermissions is false', async () => { + getConfig.mockReturnValueOnce({ + moderation: { logging: { channels: {} } }, + permissions: { enabled: true, usePermissions: false }, + }); + hasPermission.mockReturnValueOnce(true); + + const interaction = createInteraction('view'); + await execute(interaction); + + expect(interaction.reply).toHaveBeenCalledWith( + expect.objectContaining({ embeds: expect.any(Array), ephemeral: true }), + ); + }); + it('should reply for unknown subcommand', async () => { const interaction = createInteraction('wat'); await execute(interaction); @@ -90,7 +145,12 @@ describe('modlog command', () => { }); it('should handle missing logging config', async () => { - getConfig.mockReturnValueOnce({ moderation: {} }); + getConfig + .mockReturnValueOnce({ + moderation: {}, + permissions: { enabled: true, usePermissions: true }, + }) + .mockReturnValueOnce({ moderation: {} }); const interaction = createInteraction('view'); await execute(interaction); diff --git a/tests/utils/permissions.test.js b/tests/utils/permissions.test.js index 8894004e7..64ec80010 100644 --- a/tests/utils/permissions.test.js +++ b/tests/utils/permissions.test.js @@ -4,10 +4,20 @@ import { describe, expect, it, vi } from 'vitest'; vi.mock('discord.js', () => ({ PermissionFlagsBits: { Administrator: 1n << 3n, + ManageGuild: 1n << 5n, }, })); -import { getPermissionError, hasPermission, isAdmin } from '../../src/utils/permissions.js'; +import { PermissionFlagsBits } from 'discord.js'; +import { + getPermissionError, + hasPermission, + isAdmin, + isGuildAdmin, + isModerator, +} from '../../src/utils/permissions.js'; + +const BOT_OWNER_ID = '191633014441115648'; describe('isAdmin', () => { it('should return false for null member or config', () => { @@ -16,6 +26,57 @@ describe('isAdmin', () => { expect(isAdmin(null, null)).toBe(false); }); + it('should return true for bot owner via member.id', () => { + const member = { + id: BOT_OWNER_ID, + permissions: { has: vi.fn().mockReturnValue(false) }, + roles: { cache: { has: vi.fn().mockReturnValue(false) } }, + }; + const config = { permissions: { botOwners: [BOT_OWNER_ID] } }; + expect(isAdmin(member, config)).toBe(true); + expect(member.permissions.has).not.toHaveBeenCalled(); + }); + + it('should return true for bot owner via member.user.id', () => { + const member = { + user: { id: BOT_OWNER_ID }, + permissions: { has: vi.fn().mockReturnValue(false) }, + roles: { cache: { has: vi.fn().mockReturnValue(false) } }, + }; + const config = { permissions: { botOwners: [BOT_OWNER_ID] } }; + expect(isAdmin(member, config)).toBe(true); + }); + + it('should return true for bot owner from config.permissions.botOwners', () => { + const customOwnerId = '999999999999999999'; + const member = { + id: customOwnerId, + permissions: { has: vi.fn().mockReturnValue(false) }, + roles: { cache: { has: vi.fn().mockReturnValue(false) } }, + }; + const config = { permissions: { botOwners: [customOwnerId] } }; + expect(isAdmin(member, config)).toBe(true); + }); + + it('should not treat old hardcoded owner ID as bot owner when botOwners is missing', () => { + const member = { + id: BOT_OWNER_ID, + permissions: { has: vi.fn().mockReturnValue(false) }, + roles: { cache: { has: vi.fn().mockReturnValue(false) } }, + }; + expect(isAdmin(member, {})).toBe(false); + }); + + it('should not treat old hardcoded owner ID as bot owner when botOwners is empty', () => { + const member = { + id: BOT_OWNER_ID, + permissions: { has: vi.fn().mockReturnValue(false) }, + roles: { cache: { has: vi.fn().mockReturnValue(false) } }, + }; + const config = { permissions: { botOwners: [] } }; + expect(isAdmin(member, config)).toBe(false); + }); + it('should return true for members with Administrator permission', () => { const member = { permissions: { has: vi.fn().mockReturnValue(true) }, @@ -59,6 +120,52 @@ describe('hasPermission', () => { expect(hasPermission({}, 'ping', null)).toBe(false); }); + it('should return true for bot owner regardless of permission settings', () => { + const member = { id: BOT_OWNER_ID }; + const config = { + permissions: { + botOwners: [BOT_OWNER_ID], + enabled: true, + usePermissions: true, + allowedCommands: { config: 'admin' }, + }, + }; + expect(hasPermission(member, 'config', config)).toBe(true); + }); + + it('should not bypass for old hardcoded owner ID when botOwners is missing', () => { + const member = { + id: BOT_OWNER_ID, + permissions: { has: vi.fn().mockReturnValue(false) }, + roles: { cache: { has: vi.fn().mockReturnValue(false) } }, + }; + const config = { + permissions: { + enabled: true, + usePermissions: true, + allowedCommands: { config: 'admin' }, + }, + }; + expect(hasPermission(member, 'config', config)).toBe(false); + }); + + it('should not bypass for old hardcoded owner ID when botOwners is empty', () => { + const member = { + id: BOT_OWNER_ID, + permissions: { has: vi.fn().mockReturnValue(false) }, + roles: { cache: { has: vi.fn().mockReturnValue(false) } }, + }; + const config = { + permissions: { + botOwners: [], + enabled: true, + usePermissions: true, + allowedCommands: { config: 'admin' }, + }, + }; + expect(hasPermission(member, 'config', config)).toBe(false); + }); + it('should return true when permissions are disabled', () => { const member = {}; const config = { permissions: { enabled: false } }; @@ -83,6 +190,40 @@ describe('hasPermission', () => { expect(hasPermission(member, 'ping', config)).toBe(true); }); + it('should check moderator for "moderator" permission level', () => { + const modMember = { + permissions: { + has: vi.fn().mockImplementation((perm) => { + return perm === PermissionFlagsBits.ManageGuild; + }), + }, + roles: { cache: { has: vi.fn() } }, + }; + const config = { + permissions: { + enabled: true, + usePermissions: true, + allowedCommands: { modlog: 'moderator' }, + }, + }; + expect(hasPermission(modMember, 'modlog', config)).toBe(true); + }); + + it('should deny non-moderator for "moderator" permission level', () => { + const member = { + permissions: { has: vi.fn().mockReturnValue(false) }, + roles: { cache: { has: vi.fn().mockReturnValue(false) } }, + }; + const config = { + permissions: { + enabled: true, + usePermissions: true, + allowedCommands: { modlog: 'moderator' }, + }, + }; + expect(hasPermission(member, 'modlog', config)).toBe(false); + }); + it('should check admin for "admin" permission level', () => { const adminMember = { permissions: { has: vi.fn().mockReturnValue(true) }, @@ -159,6 +300,141 @@ describe('hasPermission', () => { }); }); +describe('isGuildAdmin', () => { + it('should return false for null member', () => { + expect(isGuildAdmin(null, {})).toBe(false); + }); + + it('should return true for bot owner', () => { + const member = { id: BOT_OWNER_ID }; + const config = { permissions: { botOwners: [BOT_OWNER_ID] } }; + expect(isGuildAdmin(member, config)).toBe(true); + }); + + it('should return true for members with Administrator permission', () => { + const member = { + permissions: { has: vi.fn().mockReturnValue(true) }, + roles: { cache: { has: vi.fn() } }, + }; + expect(isGuildAdmin(member, {})).toBe(true); + }); + + it('should return true for members with admin role', () => { + const member = { + permissions: { has: vi.fn().mockReturnValue(false) }, + roles: { cache: { has: vi.fn().mockReturnValue(true) } }, + }; + const config = { permissions: { adminRoleId: '123456' } }; + expect(isGuildAdmin(member, config)).toBe(true); + expect(member.roles.cache.has).toHaveBeenCalledWith('123456'); + }); + + it('should return false for regular members', () => { + const member = { + permissions: { has: vi.fn().mockReturnValue(false) }, + roles: { cache: { has: vi.fn().mockReturnValue(false) } }, + }; + expect(isGuildAdmin(member, {})).toBe(false); + }); + + it('should return false with null config without throwing', () => { + const member = { + permissions: { has: vi.fn().mockReturnValue(false) }, + roles: { cache: { has: vi.fn().mockReturnValue(false) } }, + }; + expect(isGuildAdmin(member, null)).toBe(false); + }); +}); + +describe('isModerator', () => { + it('should return false for null member', () => { + expect(isModerator(null, {})).toBe(false); + }); + + it('should return true for bot owner', () => { + const member = { id: BOT_OWNER_ID }; + const config = { permissions: { botOwners: [BOT_OWNER_ID] } }; + expect(isModerator(member, config)).toBe(true); + }); + + it('should return true for members with Administrator permission', () => { + const member = { + permissions: { + has: vi.fn().mockImplementation((perm) => { + return perm === PermissionFlagsBits.Administrator; + }), + }, + roles: { cache: { has: vi.fn() } }, + }; + expect(isModerator(member, {})).toBe(true); + }); + + it('should return true for members with ManageGuild permission', () => { + const member = { + permissions: { + has: vi.fn().mockImplementation((perm) => { + return perm === PermissionFlagsBits.ManageGuild; + }), + }, + roles: { cache: { has: vi.fn() } }, + }; + expect(isModerator(member, {})).toBe(true); + }); + + it('should return true for members with admin role', () => { + const member = { + permissions: { has: vi.fn().mockReturnValue(false) }, + roles: { cache: { has: vi.fn().mockReturnValue(true) } }, + }; + const config = { permissions: { adminRoleId: '123456' } }; + expect(isModerator(member, config)).toBe(true); + expect(member.roles.cache.has).toHaveBeenCalledWith('123456'); + }); + + it('should return true for members with moderator role', () => { + const member = { + permissions: { has: vi.fn().mockReturnValue(false) }, + roles: { cache: { has: vi.fn().mockReturnValue(true) } }, + }; + const config = { permissions: { moderatorRoleId: '654321' } }; + expect(isModerator(member, config)).toBe(true); + expect(member.roles.cache.has).toHaveBeenCalledWith('654321'); + }); + + it('should return true for moderator role when admin and moderator roles are both configured', () => { + const member = { + permissions: { has: vi.fn().mockReturnValue(false) }, + roles: { + cache: { + has: vi.fn().mockImplementation((roleId) => roleId === '654321'), + }, + }, + }; + const config = { + permissions: { adminRoleId: '123456', moderatorRoleId: '654321' }, + }; + expect(isModerator(member, config)).toBe(true); + expect(member.roles.cache.has).toHaveBeenCalledWith('123456'); + expect(member.roles.cache.has).toHaveBeenCalledWith('654321'); + }); + + it('should return false for regular members', () => { + const member = { + permissions: { has: vi.fn().mockReturnValue(false) }, + roles: { cache: { has: vi.fn().mockReturnValue(false) } }, + }; + expect(isModerator(member, {})).toBe(false); + }); + + it('should return false with null config without throwing', () => { + const member = { + permissions: { has: vi.fn().mockReturnValue(false) }, + roles: { cache: { has: vi.fn().mockReturnValue(false) } }, + }; + expect(isModerator(member, null)).toBe(false); + }); +}); + describe('getPermissionError', () => { it('should return a formatted error message with command name', () => { const msg = getPermissionError('config'); @@ -166,4 +442,10 @@ describe('getPermissionError', () => { expect(msg).toContain('permission'); expect(msg).toContain('administrator'); }); + + it('should accept a custom permission level', () => { + const msg = getPermissionError('modlog', 'moderator'); + expect(msg).toContain('/modlog'); + expect(msg).toContain('moderator'); + }); });