diff --git a/migrations/014_audit_logs_user_tag_backfill.cjs b/migrations/014_audit_logs_user_tag_backfill.cjs new file mode 100644 index 000000000..d381904d6 --- /dev/null +++ b/migrations/014_audit_logs_user_tag_backfill.cjs @@ -0,0 +1,38 @@ +/** + * Repair migration for audit_logs schema drift. + * + * Background: + * `013_audit_log.cjs` now creates `audit_logs.user_tag`, but some databases + * already had an older `audit_logs` table created before the `user_tag` + * column existed. Because `013_audit_log.cjs` uses `ifNotExists`, those + * existing tables do not receive the new column automatically. + * + * Purpose: + * Preserve the historical `014_*` slot already recorded in some databases + * and backfill the missing column/index when needed. + */ + +'use strict'; + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.up = (pgm) => { + pgm.sql(` + ALTER TABLE IF EXISTS audit_logs + ADD COLUMN IF NOT EXISTS user_tag VARCHAR(100) + `); + + pgm.sql(` + CREATE INDEX IF NOT EXISTS idx_audit_logs_guild_user + ON audit_logs(guild_id, user_id) + `); +}; + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.down = (pgm) => { + // Keep idx_audit_logs_guild_user in place because 013_audit_log.cjs also + // creates it; dropping it here would leave that migration chain inconsistent. + pgm.sql(` + ALTER TABLE IF EXISTS audit_logs + DROP COLUMN IF EXISTS user_tag + `); +}; diff --git a/src/api/middleware/auth.js b/src/api/middleware/auth.js index 4fe409126..b18e354d6 100644 --- a/src/api/middleware/auth.js +++ b/src/api/middleware/auth.js @@ -7,6 +7,8 @@ import crypto from 'node:crypto'; import { warn } from '../../logger.js'; import { handleOAuthJwt } from './oauthJwt.js'; +const DISCORD_SNOWFLAKE_PATTERN = /^\d{17,20}$/; + /** * Performs a constant-time comparison of the given secret against BOT_API_SECRET. * @@ -48,6 +50,13 @@ export function requireAuth() { }); } else if (isValidSecret(apiSecret)) { req.authMethod = 'api-secret'; + const trustedUserId = + typeof req.headers['x-discord-user-id'] === 'string' + ? req.headers['x-discord-user-id'].trim() + : ''; + if (trustedUserId && DISCORD_SNOWFLAKE_PATTERN.test(trustedUserId)) { + req.user = { userId: trustedUserId }; + } return next(); } else { // BOT_API_SECRET is configured but the provided secret doesn't match. diff --git a/src/api/middleware/rateLimit.js b/src/api/middleware/rateLimit.js index 099320497..f5cf53b35 100644 --- a/src/api/middleware/rateLimit.js +++ b/src/api/middleware/rateLimit.js @@ -3,6 +3,8 @@ * Simple in-memory per-IP rate limiter with no external dependencies */ +import { isTrustedInternalRequest } from './trustedInternalRequest.js'; + const DEFAULT_MESSAGE = 'Too many requests, please try again later'; /** @@ -56,6 +58,10 @@ export function rateLimit({ cleanup.unref(); const middleware = (req, res, next) => { + if (isTrustedInternalRequest(req)) { + return next(); + } + const ip = req.ip; const now = Date.now(); diff --git a/src/api/middleware/redisRateLimit.js b/src/api/middleware/redisRateLimit.js index 846c8de3e..a16e5a976 100644 --- a/src/api/middleware/redisRateLimit.js +++ b/src/api/middleware/redisRateLimit.js @@ -8,6 +8,7 @@ import { getRedis } from '../../redis.js'; import { rateLimit as inMemoryRateLimit } from './rateLimit.js'; +import { isTrustedInternalRequest } from './trustedInternalRequest.js'; /** * Creates Redis-backed rate limiting middleware using a sliding window counter. @@ -24,6 +25,10 @@ export function redisRateLimit({ windowMs = 15 * 60 * 1000, max = 100, keyPrefix const fallback = inMemoryRateLimit({ windowMs, max }); const middleware = async (req, res, next) => { + if (isTrustedInternalRequest(req)) { + return next(); + } + const redis = getRedis(); // Fall back to in-memory if Redis isn't available diff --git a/src/api/middleware/trustedInternalRequest.js b/src/api/middleware/trustedInternalRequest.js new file mode 100644 index 000000000..d5a258575 --- /dev/null +++ b/src/api/middleware/trustedInternalRequest.js @@ -0,0 +1,21 @@ +/** + * Trusted internal requests originate from the dashboard/web server and + * authenticate with the shared bot API secret. These requests should not + * consume the public per-IP rate-limit budget because they are proxied + * server-to-server and would otherwise all collapse to localhost in dev. + * + * @param {import('express').Request} req + * @returns {boolean} + */ +export function isTrustedInternalRequest(req) { + const expectedSecret = process.env.BOT_API_SECRET; + const providedSecret = + typeof req.get === 'function' ? req.get('x-api-secret') : req.headers?.['x-api-secret']; + + return ( + typeof expectedSecret === 'string' && + expectedSecret.length > 0 && + typeof providedSecret === 'string' && + providedSecret === expectedSecret + ); +} diff --git a/src/api/routes/guilds.js b/src/api/routes/guilds.js index 5650eafdf..8affe553a 100644 --- a/src/api/routes/guilds.js +++ b/src/api/routes/guilds.js @@ -7,7 +7,7 @@ import { Router } from 'express'; import { error, info, warn } from '../../logger.js'; import { getConfig, setConfigValue } from '../../modules/config.js'; import { cacheGetOrSet, TTL } from '../../utils/cache.js'; -import { getBotOwnerIds } from '../../utils/permissions.js'; +import { getBotOwnerIds, isAdmin, isModerator } from '../../utils/permissions.js'; import { safeSend } from '../../utils/safeSend.js'; import { maskSensitiveFields, @@ -26,6 +26,8 @@ const router = Router(); const ADMINISTRATOR_FLAG = 0x8; /** Discord MANAGE_GUILD permission flag */ const MANAGE_GUILD_FLAG = 0x20; +const ACCESS_LOOKUP_CONCURRENCY = 10; +const MAX_ACCESS_LOOKUP_GUILDS = 100; /** * Upper bound on content length for abuse prevention. @@ -235,21 +237,107 @@ function isOAuthGuildModerator(user, guildId) { return hasOAuthGuildPermission(user, guildId, ADMINISTRATOR_FLAG | MANAGE_GUILD_FLAG); } +function accessSatisfiesRequirement(access, requiredAccess) { + if (access === 'bot-owner') return true; + if (requiredAccess === 'admin') return access === 'admin'; + return access === 'admin' || access === 'moderator'; +} + +function hasPermissionFlag(permissions, flag) { + try { + return (BigInt(permissions) & BigInt(flag)) === BigInt(flag); + } catch { + return false; + } +} + +function getOAuthDerivedAccessLevel(owner, permissions) { + if (owner) return 'admin'; + if (hasPermissionFlag(permissions, ADMINISTRATOR_FLAG)) return 'admin'; + if (hasPermissionFlag(permissions, MANAGE_GUILD_FLAG)) return 'moderator'; + return null; +} + +function isUnknownMemberError(err) { + return err?.code === 10007 || err?.message?.includes('Unknown Member'); +} + +async function mapWithConcurrency(items, concurrency, iteratee) { + const results = new Array(items.length); + let index = 0; + + async function worker() { + while (index < items.length) { + const currentIndex = index++; + results[currentIndex] = await iteratee(items[currentIndex], currentIndex); + } + } + + const workerCount = Math.min(concurrency, items.length); + await Promise.all(Array.from({ length: workerCount }, () => worker())); + return results; +} + +/** + * Resolve dashboard access for a guild member using the bot's configured role rules. + * + * @param {import('discord.js').Guild} guild + * @param {string} userId + * @returns {Promise<'bot-owner'|'admin'|'moderator'|'viewer'>} + */ +async function getGuildAccessLevel(guild, userId) { + const config = getConfig(guild.id); + + if (getBotOwnerIds(config).includes(userId)) { + return 'bot-owner'; + } + + let member = guild.members.cache.get(userId) || null; + if (!member && typeof guild.members?.fetch === 'function') { + try { + member = await guild.members.fetch(userId); + } catch (err) { + if (isUnknownMemberError(err)) { + member = null; + } else { + throw err; + } + } + } + + if (!member) { + return 'viewer'; + } + + if (isAdmin(member, config)) { + return 'admin'; + } + + if (isModerator(member, config)) { + return 'moderator'; + } + + return 'viewer'; +} + /** * Return Express middleware that enforces a guild-level permission for OAuth users. * * The middleware bypasses checks for API-secret requests and for configured bot owners. - * For OAuth-authenticated requests it calls `permissionCheck(user, guildId)` and: - * - responds 403 with `errorMessage` when the check resolves to `false`, + * For cached bot guilds it resolves dashboard access via `getGuildAccessLevel(...)`; + * otherwise it falls back to `permissionCheck(user, guildId)`. The resolved access + * level must satisfy `requiredAccess`. + * - responds 403 with `errorMessage` when the resolved access is insufficient, * - responds 502 when the permission verification throws, * - otherwise allows the request to continue. * Unknown or missing auth methods receive a 401 response. * * @param {(user: Object, guildId: string) => Promise} permissionCheck - Function that returns `true` if the provided user has the required permission in the specified guild, `false` otherwise. * @param {string} errorMessage - Message to include in the 403 response when permission is denied. + * @param {'moderator'|'admin'} requiredAccess - Minimum dashboard access level required for the route. * @returns {import('express').RequestHandler} Express middleware enforcing the permission. */ -function requireGuildPermission(permissionCheck, errorMessage) { +function requireGuildPermission(permissionCheck, errorMessage, requiredAccess) { return async (req, res, next) => { if (req.authMethod === 'api-secret') return next(); @@ -257,6 +345,15 @@ function requireGuildPermission(permissionCheck, errorMessage) { if (isOAuthBotOwner(req.user)) return next(); try { + const guild = req.app.locals.client?.guilds?.cache?.get(req.params.id); + if (guild) { + const access = await getGuildAccessLevel(guild, req.user.userId); + if (!accessSatisfiesRequirement(access, requiredAccess)) { + return res.status(403).json({ error: errorMessage }); + } + return next(); + } + if (!(await permissionCheck(req.user, req.params.id))) { return res.status(403).json({ error: errorMessage }); } @@ -283,12 +380,14 @@ function requireGuildPermission(permissionCheck, errorMessage) { export const requireGuildAdmin = requireGuildPermission( isOAuthGuildAdmin, 'You do not have admin access to this guild', + 'admin', ); /** Middleware: verify OAuth2 users are guild moderators. API-secret users pass through. */ export const requireGuildModerator = requireGuildPermission( isOAuthGuildModerator, 'You do not have moderator access to this guild', + 'moderator', ); /** @@ -390,27 +489,29 @@ router.get('/', async (req, res) => { 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 resolvedGuilds = await mapWithConcurrency( + userGuilds, + ACCESS_LOOKUP_CONCURRENCY, + async (ug) => { 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; - }, []); + if (!botGuild) return null; + + const access = + getOAuthDerivedAccessLevel(ug.owner, ug.permissions) ?? + (await getGuildAccessLevel(botGuild, req.user.userId)); + if (access === 'viewer') return null; - return res.json(filtered); + return { + id: ug.id, + name: botGuild.name, + icon: botGuild.iconURL(), + memberCount: botGuild.memberCount, + access, + }; + }, + ); + + return res.json(resolvedGuilds.filter(Boolean)); } catch (err) { error('Failed to fetch user guilds from Discord', { error: err.message, @@ -435,6 +536,63 @@ router.get('/', async (req, res) => { return res.status(401).json({ error: 'Unauthorized' }); }); +router.get('/access', async (req, res) => { + if (req.authMethod !== 'api-secret') { + return res + .status(401) + .json({ error: 'Guild access endpoint requires API secret authentication' }); + } + + const userId = typeof req.query.userId === 'string' ? req.query.userId.trim() : ''; + const guildIdsRaw = typeof req.query.guildIds === 'string' ? req.query.guildIds : ''; + + if (!userId) { + return res.status(400).json({ error: 'Missing userId query parameter' }); + } + + const guildIds = [ + ...new Set( + guildIdsRaw + .split(',') + .map((id) => id.trim()) + .filter(Boolean), + ), + ]; + if (guildIds.length === 0) { + return res.json([]); + } + if (guildIds.length > MAX_ACCESS_LOOKUP_GUILDS) { + return res.status(400).json({ + error: `guildIds may include at most ${MAX_ACCESS_LOOKUP_GUILDS} entries`, + }); + } + + const { client } = req.app.locals; + + try { + const accessEntries = await mapWithConcurrency( + guildIds, + ACCESS_LOOKUP_CONCURRENCY, + async (guildId) => { + const guild = client.guilds.cache.get(guildId); + if (!guild) return null; + + const access = await getGuildAccessLevel(guild, userId); + return { id: guildId, access }; + }, + ); + + return res.json(accessEntries.filter(Boolean)); + } catch (err) { + error('Failed to resolve guild access entries', { + error: err.message, + userId, + guildCount: guildIds.length, + }); + return res.status(502).json({ error: 'Failed to verify guild permissions with Discord' }); + } +}); + /** Maximum number of channels to return to avoid oversized payloads. */ const MAX_CHANNELS = 500; diff --git a/src/api/routes/members.js b/src/api/routes/members.js index 21ff9207f..e8db12f44 100644 --- a/src/api/routes/members.js +++ b/src/api/routes/members.js @@ -12,7 +12,7 @@ import { computeLevel, getXpConfig } from '../../modules/reputation.js'; import { cacheGet, cacheSet, TTL } from '../../utils/cache.js'; import { rateLimit } from '../middleware/rateLimit.js'; import { parseLimit, parsePage } from '../utils/pagination.js'; -import { requireGuildAdmin, validateGuild } from './guilds.js'; +import { requireGuildModerator, validateGuild } from './guilds.js'; const router = Router(); @@ -74,7 +74,7 @@ function safeGetPool() { router.get( '/:id/members/export', membersRateLimit, - requireGuildAdmin, + requireGuildModerator, validateGuild, async (req, res) => { try { @@ -286,7 +286,7 @@ router.get( * "503": * $ref: "#/components/responses/ServiceUnavailable" */ -router.get('/:id/members', membersRateLimit, requireGuildAdmin, validateGuild, async (req, res) => { +router.get('/:id/members', membersRateLimit, requireGuildModerator, validateGuild, async (req, res) => { const limit = parseLimit(req.query.limit); const after = req.query.after || undefined; const search = req.query.search || undefined; @@ -598,7 +598,7 @@ router.get('/:id/members', membersRateLimit, requireGuildAdmin, validateGuild, a router.get( '/:id/members/:userId', membersRateLimit, - requireGuildAdmin, + requireGuildModerator, validateGuild, async (req, res) => { const { userId } = req.params; @@ -797,7 +797,7 @@ router.get( router.get( '/:id/members/:userId/cases', membersRateLimit, - requireGuildAdmin, + requireGuildModerator, validateGuild, async (req, res) => { const { userId } = req.params; @@ -849,7 +849,7 @@ router.get( }, ); -// ─── POST /:id/members/:userId/xp — Admin XP adjustment ────────────────────── +// ─── POST /:id/members/:userId/xp — Moderator XP adjustment ────────────────── /** * @openapi @@ -928,7 +928,7 @@ router.get( router.post( '/:id/members/:userId/xp', membersRateLimit, - requireGuildAdmin, + requireGuildModerator, validateGuild, async (req, res) => { const { userId } = req.params; diff --git a/src/api/routes/tickets.js b/src/api/routes/tickets.js index 3669c3ce1..036873dde 100644 --- a/src/api/routes/tickets.js +++ b/src/api/routes/tickets.js @@ -9,7 +9,7 @@ import { Router } from 'express'; import { error as logError } from '../../logger.js'; import { rateLimit } from '../middleware/rateLimit.js'; import { parseLimit, parsePage } from '../utils/pagination.js'; -import { requireGuildAdmin, validateGuild } from './guilds.js'; +import { requireGuildModerator, validateGuild } from './guilds.js'; const router = Router(); @@ -74,7 +74,7 @@ function getDbPool(req) { router.get( '/:id/tickets/stats', ticketRateLimit, - requireGuildAdmin, + requireGuildModerator, validateGuild, async (req, res) => { const { id: guildId } = req.params; @@ -202,7 +202,7 @@ router.get( router.get( '/:id/tickets/:ticketId', ticketRateLimit, - requireGuildAdmin, + requireGuildModerator, validateGuild, async (req, res) => { const { id: guildId, ticketId } = req.params; @@ -334,7 +334,7 @@ router.get( * "503": * $ref: "#/components/responses/ServiceUnavailable" */ -router.get('/:id/tickets', ticketRateLimit, requireGuildAdmin, validateGuild, async (req, res) => { +router.get('/:id/tickets', ticketRateLimit, requireGuildModerator, validateGuild, async (req, res) => { const { id: guildId } = req.params; const { status, user } = req.query; const page = parsePage(req.query.page); diff --git a/src/utils/loadCommands.js b/src/utils/loadCommands.js index 60fefaf66..9c6ed15a2 100644 --- a/src/utils/loadCommands.js +++ b/src/utils/loadCommands.js @@ -1,5 +1,6 @@ import { readdirSync } from 'node:fs'; import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; import { error as logError, info as logInfo, warn as logWarn } from '../logger.js'; const defaultCommandLogger = { @@ -31,7 +32,7 @@ export async function loadCommandsFromDirectory({ const filePath = join(commandsPath, file); try { - const command = await import(filePath); + const command = await import(pathToFileURL(filePath).href); if (!command.data || !command.execute) { commandLogger.warn('Command missing data or execute export', { file }); diff --git a/tests/api/middleware/auth.test.js b/tests/api/middleware/auth.test.js index 3debac189..6b365a0e9 100644 --- a/tests/api/middleware/auth.test.js +++ b/tests/api/middleware/auth.test.js @@ -136,6 +136,61 @@ describe('auth middleware', () => { expect(res.status).not.toHaveBeenCalled(); }); + it('should attach trusted actor identity for valid api-secret requests', async () => { + vi.stubEnv('BOT_API_SECRET', 'test-secret'); + req.headers['x-api-secret'] = 'test-secret'; + req.headers['x-discord-user-id'] = '123456789012345678'; + const middleware = requireAuth(); + + await middleware(req, res, next); + + expect(next).toHaveBeenCalled(); + expect(req.authMethod).toBe('api-secret'); + expect(req.user).toEqual({ userId: '123456789012345678' }); + }); + + it('should ignore missing or blank trusted actor identity on valid api-secret requests', async () => { + vi.stubEnv('BOT_API_SECRET', 'test-secret'); + req.headers['x-api-secret'] = 'test-secret'; + const middleware = requireAuth(); + + await middleware(req, res, next); + + expect(next).toHaveBeenCalled(); + expect(req.authMethod).toBe('api-secret'); + expect(req.user).toBeUndefined(); + + req = { + headers: { 'x-api-secret': 'test-secret', 'x-discord-user-id': ' ' }, + ip: '127.0.0.1', + path: '/test', + }; + res = { + status: vi.fn().mockReturnThis(), + json: vi.fn().mockReturnThis(), + }; + next = vi.fn(); + + await middleware(req, res, next); + + expect(next).toHaveBeenCalled(); + expect(req.authMethod).toBe('api-secret'); + expect(req.user).toBeUndefined(); + }); + + it('should ignore invalid trusted actor identity on valid api-secret requests', async () => { + vi.stubEnv('BOT_API_SECRET', 'test-secret'); + req.headers['x-api-secret'] = 'test-secret'; + req.headers['x-discord-user-id'] = 'not-a-snowflake'; + const middleware = requireAuth(); + + await middleware(req, res, next); + + expect(next).toHaveBeenCalled(); + expect(req.authMethod).toBe('api-secret'); + expect(req.user).toBeUndefined(); + }); + it('should authenticate with valid JWT Bearer token', async () => { vi.stubEnv('SESSION_SECRET', 'jwt-test-secret'); sessionStore.set('123', 'discord-access-token'); diff --git a/tests/api/middleware/rateLimit.test.js b/tests/api/middleware/rateLimit.test.js index 810c85d88..2bdb0e53b 100644 --- a/tests/api/middleware/rateLimit.test.js +++ b/tests/api/middleware/rateLimit.test.js @@ -20,6 +20,7 @@ describe('rateLimit middleware', () => { afterEach(() => { vi.useRealTimers(); vi.clearAllMocks(); + vi.unstubAllEnvs(); }); it('should allow requests within the limit', () => { @@ -190,4 +191,19 @@ describe('rateLimit middleware', () => { }); expect(next).toHaveBeenCalledTimes(1); // only the first (allowed) request called next }); + + it('should skip rate limiting for trusted internal requests', () => { + vi.stubEnv('BOT_API_SECRET', 'test-secret'); + req.get = vi.fn().mockReturnValue('test-secret'); + + const middleware = rateLimit({ windowMs: 60000, max: 1 }); + + middleware(req, res, next); + middleware(req, res, next); + middleware(req, res, next); + + expect(next).toHaveBeenCalledTimes(3); + expect(res.status).not.toHaveBeenCalled(); + expect(res.set).not.toHaveBeenCalledWith('Retry-After', expect.any(String)); + }); }); diff --git a/tests/api/middleware/redisRateLimit.test.js b/tests/api/middleware/redisRateLimit.test.js index 25e9dae46..d4b127258 100644 --- a/tests/api/middleware/redisRateLimit.test.js +++ b/tests/api/middleware/redisRateLimit.test.js @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; // Mock logger vi.mock('../../../src/logger.js', () => ({ @@ -40,8 +40,13 @@ describe('redisRateLimit', () => { redisRateLimit = mod.redisRateLimit; }); + afterEach(() => { + vi.unstubAllEnvs(); + vi.clearAllMocks(); + }); + function makeReq(ip = '127.0.0.1') { - return { ip }; + return { ip, get: vi.fn().mockReturnValue(undefined) }; } function makeRes() { @@ -140,4 +145,20 @@ describe('redisRateLimit', () => { const middleware = redisRateLimit(); expect(() => middleware.destroy()).not.toThrow(); }); + + it('skips rate limiting for trusted internal requests before touching Redis', async () => { + vi.stubEnv('BOT_API_SECRET', 'test-secret'); + const req = makeReq(); + req.get.mockReturnValue('test-secret'); + const res = makeRes(); + const next = vi.fn(); + + const middleware = redisRateLimit({ max: 1 }); + + await middleware(req, res, next); + + expect(next).toHaveBeenCalledTimes(1); + expect(getRedis).not.toHaveBeenCalled(); + expect(res.status).not.toHaveBeenCalled(); + }); }); diff --git a/tests/api/routes/guilds.coverage.test.js b/tests/api/routes/guilds.coverage.test.js index 8d0c5202b..a39ca40de 100644 --- a/tests/api/routes/guilds.coverage.test.js +++ b/tests/api/routes/guilds.coverage.test.js @@ -30,11 +30,17 @@ vi.mock('../../../src/modules/config.js', () => ({ vi.mock('../../../src/utils/safeSend.js', () => ({ safeSend: vi.fn().mockResolvedValue({ id: 'msg1', content: 'Hello!' }), })); +vi.mock('../../../src/utils/permissions.js', () => ({ + getBotOwnerIds: vi.fn().mockReturnValue([]), + isAdmin: vi.fn().mockReturnValue(false), + isModerator: vi.fn().mockReturnValue(false), +})); 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 { isAdmin, isModerator } from '../../../src/utils/permissions.js'; import { safeSend } from '../../../src/utils/safeSend.js'; const SECRET = 'test-secret'; @@ -98,6 +104,65 @@ describe('guilds routes coverage', () => { app = createApp(client, mockPool); }); + describe('GET /access', () => { + it('returns access levels based on configured moderator/admin role checks', async () => { + isAdmin.mockReset().mockReturnValue(false); + isModerator.mockReset().mockReturnValue(true); + + const res = await request(app) + .get('/api/v1/guilds/access?userId=user1&guildIds=guild1') + .set('x-api-secret', SECRET); + + expect(res.status).toBe(200); + expect(res.body).toEqual([{ id: 'guild1', access: 'moderator' }]); + expect(isModerator).toHaveBeenCalled(); + }); + + it('returns viewer for unknown members but 502 for transient Discord failures', async () => { + const originalCache = mockGuild.members.cache; + const originalFetch = mockGuild.members.fetch; + mockGuild.members.cache = new Map(); + + mockGuild.members.fetch = vi + .fn() + .mockRejectedValueOnce(Object.assign(new Error('Unknown Member'), { code: 10007 })); + let res = await request(app) + .get('/api/v1/guilds/access?userId=user1&guildIds=guild1') + .set('x-api-secret', SECRET); + + expect(res.status).toBe(200); + expect(res.body).toEqual([{ id: 'guild1', access: 'viewer' }]); + + mockGuild.members.fetch = vi.fn().mockRejectedValueOnce(new Error('Discord timeout')); + res = await request(app) + .get('/api/v1/guilds/access?userId=user1&guildIds=guild1') + .set('x-api-secret', SECRET); + + expect(res.status).toBe(502); + expect(res.body).toEqual({ error: 'Failed to verify guild permissions with Discord' }); + + mockGuild.members.cache = originalCache; + mockGuild.members.fetch = originalFetch; + }); + + it('rejects non-api-secret callers', async () => { + const res = await request(app).get('/api/v1/guilds/access?userId=user1&guildIds=guild1'); + + expect(res.status).toBe(401); + }); + + it('rejects oversized guild access batches', async () => { + const guildIds = Array.from({ length: 101 }, (_, index) => `guild-${index}`).join(','); + + const res = await request(app) + .get(`/api/v1/guilds/access?userId=user1&guildIds=${guildIds}`) + .set('x-api-secret', SECRET); + + expect(res.status).toBe(400); + expect(res.body.error).toContain('at most 100'); + }); + }); + afterEach(() => { sessionStore.clear(); guildCache.clear(); @@ -190,7 +255,7 @@ describe('guilds routes coverage', () => { .send({ action: 'sendMessage', channelId: 'ch1', content: 'hello' }); expect(res.status).toBe(403); - expect(res.body.error).toContain('API secret'); + expect(res.body.error).toContain('admin access'); }); it('returns 400 when body is missing', async () => { diff --git a/tests/api/routes/guilds.test.js b/tests/api/routes/guilds.test.js index a5f42499b..2541f6df6 100644 --- a/tests/api/routes/guilds.test.js +++ b/tests/api/routes/guilds.test.js @@ -1,3 +1,4 @@ +import { PermissionFlagsBits } from 'discord.js'; import jwt from 'jsonwebtoken'; import request from 'supertest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -71,6 +72,7 @@ import { safeSend } from '../../../src/utils/safeSend.js'; describe('guilds routes', () => { let app; let mockPool; + let permissionState; const SECRET = 'test-secret'; const mockChannel = { @@ -98,6 +100,17 @@ describe('guilds routes', () => { user: { username: 'testuser', bot: false }, displayName: 'Test User', roles: { cache: new Map([['role1', { id: 'role1', name: 'Admin' }]]) }, + permissions: { + has: vi.fn((permission) => { + if (permission === PermissionFlagsBits.Administrator) { + return permissionState.administrator; + } + if (permission === PermissionFlagsBits.ManageGuild) { + return permissionState.manageGuild; + } + return false; + }), + }, joinedAt: new Date('2024-01-01'), joinedTimestamp: new Date('2024-01-01').getTime(), presence: { status: 'online' }, @@ -113,11 +126,18 @@ describe('guilds routes', () => { members: { cache: new Map([['user1', mockMember]]), list: vi.fn().mockResolvedValue(new Map([['user1', mockMember]])), + fetch: vi.fn().mockImplementation((userId) => { + if (userId === 'user1') { + return Promise.resolve(mockMember); + } + return Promise.reject(Object.assign(new Error('Unknown Member'), { code: 10007 })); + }), }, }; beforeEach(() => { vi.stubEnv('BOT_API_SECRET', SECRET); + permissionState = { administrator: true, manageGuild: false }; mockPool = { query: vi.fn(), @@ -144,7 +164,7 @@ describe('guilds routes', () => { /** * Helper: create a JWT and populate the server-side session store */ - function createOAuthToken(secret = 'jwt-test-secret', userId = '123') { + function createOAuthToken(secret = 'jwt-test-secret', userId = 'user1') { const jti = `test-jti-${userId}`; sessionStore.set(userId, { accessToken: 'discord-access-token', jti }); return jwt.sign( @@ -165,6 +185,11 @@ describe('guilds routes', () => { }); } + function setGuildMemberPermissions({ administrator = false, manageGuild = false } = {}) { + permissionState.administrator = administrator; + permissionState.manageGuild = manageGuild; + } + describe('authentication', () => { it('should return 401 without x-api-secret header', async () => { const res = await request(app).get('/api/v1/guilds/guild1'); @@ -232,6 +257,7 @@ describe('guilds routes', () => { it('should return OAuth guilds with access metadata', async () => { vi.stubEnv('SESSION_SECRET', 'jwt-test-secret'); const token = createOAuthToken(); + setGuildMemberPermissions({ administrator: true }); mockFetchGuilds([ { id: 'guild1', name: 'Test Server', permissions: '8' }, { id: 'guild-not-in-bot', name: 'Other Server', permissions: '8' }, @@ -249,6 +275,7 @@ describe('guilds routes', () => { it('should include guilds where OAuth user has MANAGE_GUILD', async () => { vi.stubEnv('SESSION_SECRET', 'jwt-test-secret'); const token = createOAuthToken(); + setGuildMemberPermissions({ administrator: false, manageGuild: true }); // 0x20 = MANAGE_GUILD but not ADMINISTRATOR mockFetchGuilds([{ id: 'guild1', name: 'Test Server', permissions: '32' }]); @@ -263,11 +290,32 @@ describe('guilds routes', () => { it('should include admin and moderator access values when both are present', async () => { vi.stubEnv('SESSION_SECRET', 'jwt-test-secret'); const token = createOAuthToken(); + setGuildMemberPermissions({ administrator: true }); const mockGuild2 = { ...mockGuild, id: 'guild2', name: 'Second Server', memberCount: 50, + members: { + ...mockGuild.members, + cache: new Map([ + [ + 'user1', + { + ...mockMember, + permissions: { + has: vi.fn((permission) => permission === PermissionFlagsBits.ManageGuild), + }, + }, + ], + ]), + fetch: vi.fn().mockResolvedValue({ + ...mockMember, + permissions: { + has: vi.fn((permission) => permission === PermissionFlagsBits.ManageGuild), + }, + }), + }, }; const client = { guilds: { @@ -314,15 +362,25 @@ describe('guilds routes', () => { expect(fetchSpy).not.toHaveBeenCalled(); }); - it('should exclude guilds where OAuth user has no admin permissions', async () => { + it('should exclude guilds where OAuth access resolves to viewer', async () => { vi.stubEnv('SESSION_SECRET', 'jwt-test-secret'); const token = createOAuthToken(); + setGuildMemberPermissions({ administrator: false, manageGuild: false }); + const originalCache = mockGuild.members.cache; + const originalFetch = mockGuild.members.fetch; + mockGuild.members.cache = new Map(); + mockGuild.members.fetch = vi + .fn() + .mockRejectedValueOnce(Object.assign(new Error('Unknown Member'), { code: 10007 })); 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); + + mockGuild.members.cache = originalCache; + mockGuild.members.fetch = originalFetch; }); }); @@ -368,6 +426,7 @@ describe('guilds routes', () => { it('should allow OAuth users with admin permission on guild', async () => { vi.stubEnv('SESSION_SECRET', 'jwt-test-secret'); const token = createOAuthToken(); + setGuildMemberPermissions({ administrator: true }); mockFetchGuilds([{ id: 'guild1', name: 'Test', permissions: '8' }]); const res = await request(app) @@ -380,7 +439,7 @@ describe('guilds routes', () => { 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 + setGuildMemberPermissions({ administrator: false, manageGuild: true }); mockFetchGuilds([{ id: 'guild1', name: 'Test', permissions: '32' }]); const res = await request(app) @@ -394,6 +453,7 @@ describe('guilds routes', () => { it('should deny OAuth users without admin or manage-guild permission', async () => { vi.stubEnv('SESSION_SECRET', 'jwt-test-secret'); const token = createOAuthToken(); + setGuildMemberPermissions({ administrator: false, manageGuild: false }); mockFetchGuilds([{ id: 'guild1', name: 'Test', permissions: '0' }]); const res = await request(app) @@ -407,6 +467,12 @@ describe('guilds routes', () => { it('should deny OAuth users not in the guild', async () => { vi.stubEnv('SESSION_SECRET', 'jwt-test-secret'); const token = createOAuthToken(); + const originalCache = mockGuild.members.cache; + const originalFetch = mockGuild.members.fetch; + mockGuild.members.cache = new Map(); + mockGuild.members.fetch = vi + .fn() + .mockRejectedValueOnce(Object.assign(new Error('Unknown Member'), { code: 10007 })); mockFetchGuilds([{ id: 'other-guild', name: 'Other', permissions: '8' }]); const res = await request(app) @@ -415,6 +481,9 @@ describe('guilds routes', () => { expect(res.status).toBe(403); expect(res.body.error).toContain('admin access'); + + mockGuild.members.cache = originalCache; + mockGuild.members.fetch = originalFetch; }); it('should allow bot-owner OAuth users to access admin endpoints without Discord fetch', async () => { @@ -1163,6 +1232,7 @@ describe('guilds routes', () => { it('should allow OAuth users with MANAGE_GUILD permission', async () => { vi.stubEnv('SESSION_SECRET', 'jwt-test-secret'); const token = createOAuthToken(); + setGuildMemberPermissions({ administrator: false, manageGuild: true }); 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' }], @@ -1202,6 +1272,7 @@ describe('guilds routes', () => { it('should deny OAuth users without moderator permissions', async () => { vi.stubEnv('SESSION_SECRET', 'jwt-test-secret'); const token = createOAuthToken(); + setGuildMemberPermissions({ administrator: false, manageGuild: false }); mockFetchGuilds([{ id: 'guild1', name: 'Test', permissions: '0' }]); const res = await request(app) diff --git a/tests/api/routes/members.test.js b/tests/api/routes/members.test.js index 02f05edf6..8e6fb1332 100644 --- a/tests/api/routes/members.test.js +++ b/tests/api/routes/members.test.js @@ -54,6 +54,7 @@ vi.mock('../../../src/api/middleware/oauthJwt.js', () => ({ import { createApp } from '../../../src/api/server.js'; import { getPool } from '../../../src/db.js'; +import { info } from '../../../src/logger.js'; const TEST_SECRET = 'test-members-secret'; @@ -507,6 +508,32 @@ describe('members routes', () => { expect(res.body.adjustment).toBe(-200); }); + it('should attribute XP adjustments to the forwarded moderator identity', async () => { + const mockClient = { + query: vi + .fn() + .mockResolvedValueOnce({}) + .mockResolvedValueOnce({ rows: [{ xp: 350, level: 2 }] }) + .mockResolvedValueOnce({}) + .mockResolvedValueOnce({}), + release: vi.fn(), + }; + mockPool.connect.mockResolvedValueOnce(mockClient); + + const res = await authed( + request(app) + .post('/api/v1/guilds/guild1/members/user1/xp') + .set('x-discord-user-id', '123456789012345678') + .send({ amount: 100 }), + ); + + expect(res.status).toBe(200); + expect(info).toHaveBeenCalledWith( + 'XP adjusted via API', + expect.objectContaining({ adjustedBy: '123456789012345678' }), + ); + }); + it('should reject fractional XP amount', async () => { const res = await authed( request(app).post('/api/v1/guilds/guild1/members/user1/xp').send({ amount: 1.5 }), diff --git a/tests/commands.test.js b/tests/commands.test.js index 87cdfadaa..86a289437 100644 --- a/tests/commands.test.js +++ b/tests/commands.test.js @@ -1,6 +1,6 @@ import { readdirSync } from 'node:fs'; import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; +import { fileURLToPath, pathToFileURL } from 'node:url'; import { beforeAll, describe, expect, it } from 'vitest'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -21,7 +21,7 @@ describe('command files', () => { let mod; beforeAll(async () => { - mod = await import(join(commandsDir, file)); + mod = await import(pathToFileURL(join(commandsDir, file)).href); }); it('should export data and execute', () => { diff --git a/tests/modules/ai.test.js b/tests/modules/ai.test.js index 7b5a07a3a..a064bc550 100644 --- a/tests/modules/ai.test.js +++ b/tests/modules/ai.test.js @@ -49,7 +49,9 @@ describe('ai module', () => { addToHistory('ch1', 'user', 'hello'); const history = await getHistoryAsync('ch1'); expect(history.length).toBe(1); - expect(history[0]).toEqual(expect.objectContaining({ role: 'user', content: 'hello' })); + expect(history[0]).toEqual( + expect.objectContaining({ role: 'user', content: 'hello', timestamp: expect.any(Number) }), + ); }); it('should hydrate DB history in-place when concurrent messages are added', async () => { @@ -84,9 +86,21 @@ describe('ai module', () => { await vi.waitFor(() => { expect(historyRef).toEqual([ - expect.objectContaining({ role: 'user', content: 'db message' }), - expect.objectContaining({ role: 'assistant', content: 'db reply' }), - expect.objectContaining({ role: 'user', content: 'concurrent message' }), + expect.objectContaining({ + role: 'user', + content: 'db message', + timestamp: expect.any(Number), + }), + expect.objectContaining({ + role: 'assistant', + content: 'db reply', + timestamp: expect.any(Number), + }), + expect.objectContaining({ + role: 'user', + content: 'concurrent message', + timestamp: expect.any(Number), + }), ]); expect(getConversationHistory().get('race-channel')).toBe(historyRef); }); @@ -107,7 +121,7 @@ describe('ai module', () => { expect(history[0].content).toBe('from db'); expect(history[1].content).toBe('response'); expect(mockQuery).toHaveBeenCalledWith( - expect.stringContaining('SELECT role, content FROM conversations'), + expect.stringContaining('SELECT role, content, created_at FROM conversations'), ['ch-new', 20], ); }); diff --git a/tests/modules/cli-process.test.js b/tests/modules/cli-process.test.js index a016e5232..b077b4f31 100644 --- a/tests/modules/cli-process.test.js +++ b/tests/modules/cli-process.test.js @@ -643,17 +643,20 @@ describe('CLIProcess — long-lived mode', () => { }); it('should reject on timeout in long-lived mode', async () => { - vi.useFakeTimers({ shouldAdvanceTime: true }); + vi.useFakeTimers(); const cli = new CLIProcess('test-ll', {}, { streaming: true, timeout: 3000 }); await cli.start(); const sendP = cli.send('slow'); + const rejection = sendP.catch((error) => error); - vi.advanceTimersByTime(3001); + await vi.advanceTimersByTimeAsync(3001); - await expect(sendP).rejects.toThrow('timed out after 3000ms'); + const error = await rejection; + expect(error).toBeInstanceOf(CLIProcessError); + expect(error.message).toContain('timed out after 3000ms'); expect(fakeProc.kill).toHaveBeenCalledWith('SIGKILL'); - }); + }, 30_000); it('should reject when process exits unexpectedly while awaiting result', async () => { const cli = new CLIProcess('test-ll', {}, { streaming: true }); diff --git a/vitest.config.js b/vitest.config.js index 91619a7cf..8a88b527c 100644 --- a/vitest.config.js +++ b/vitest.config.js @@ -5,7 +5,8 @@ export default defineConfig({ globals: false, environment: 'node', include: ['tests/**/*.test.js'], - testTimeout: 10000, + testTimeout: 30000, + hookTimeout: 30000, coverage: { provider: 'v8', reporter: ['text', 'lcov'], diff --git a/web/src/app/api/guilds/[guildId]/members/[userId]/cases/route.ts b/web/src/app/api/guilds/[guildId]/members/[userId]/cases/route.ts index e858b4310..139efa957 100644 --- a/web/src/app/api/guilds/[guildId]/members/[userId]/cases/route.ts +++ b/web/src/app/api/guilds/[guildId]/members/[userId]/cases/route.ts @@ -1,7 +1,7 @@ import type { NextRequest } from 'next/server'; import { NextResponse } from 'next/server'; import { - authorizeGuildAdmin, + authorizeGuildModerator, buildUpstreamUrl, getBotApiConfig, proxyToBotApi, @@ -14,7 +14,7 @@ const LOG_PREFIX = '[api/guilds/:guildId/members/:userId/cases]'; /** * Proxy a guild member's moderation case history request to the bot API. * - * Validates route parameters, enforces guild-admin authorization, forwards the original query parameters, and returns the upstream bot API response. + * Validates route parameters, enforces guild-moderator authorization, forwards the original query parameters, and returns the upstream bot API response. * * @returns The NextResponse from the bot API proxy, or an error NextResponse (for example, 400 when `guildId` or `userId` is missing, or an authorization error response). */ @@ -27,7 +27,7 @@ export async function GET( return NextResponse.json({ error: 'Missing guildId or userId' }, { status: 400 }); } - const authError = await authorizeGuildAdmin(request, guildId, LOG_PREFIX); + const authError = await authorizeGuildModerator(request, guildId, LOG_PREFIX); if (authError) return authError; const apiConfig = getBotApiConfig(LOG_PREFIX); diff --git a/web/src/app/api/guilds/[guildId]/members/[userId]/route.ts b/web/src/app/api/guilds/[guildId]/members/[userId]/route.ts index 54bbf73ea..f959c30ec 100644 --- a/web/src/app/api/guilds/[guildId]/members/[userId]/route.ts +++ b/web/src/app/api/guilds/[guildId]/members/[userId]/route.ts @@ -1,7 +1,7 @@ import type { NextRequest } from 'next/server'; import { NextResponse } from 'next/server'; import { - authorizeGuildAdmin, + authorizeGuildModerator, buildUpstreamUrl, getBotApiConfig, proxyToBotApi, @@ -14,7 +14,7 @@ const LOG_PREFIX = '[api/guilds/:guildId/members/:userId]'; /** * Proxy a GET request for a guild member's details to the bot API. * - * Validates required path parameters, enforces guild admin authorization, builds the upstream URL, + * Validates required path parameters, enforces guild moderator authorization, builds the upstream URL, * and forwards the request to the bot API. Returns a 400 response if `guildId` or `userId` is missing, * returns any authorization or configuration error responses produced during processing, and otherwise * returns the proxied bot API response (or an error response if proxying fails). @@ -31,7 +31,7 @@ export async function GET( return NextResponse.json({ error: 'Missing guildId or userId' }, { status: 400 }); } - const authError = await authorizeGuildAdmin(request, guildId, LOG_PREFIX); + const authError = await authorizeGuildModerator(request, guildId, LOG_PREFIX); if (authError) return authError; const apiConfig = getBotApiConfig(LOG_PREFIX); diff --git a/web/src/app/api/guilds/[guildId]/members/[userId]/xp/route.ts b/web/src/app/api/guilds/[guildId]/members/[userId]/xp/route.ts index eb627ffba..0e0c5e7c3 100644 --- a/web/src/app/api/guilds/[guildId]/members/[userId]/xp/route.ts +++ b/web/src/app/api/guilds/[guildId]/members/[userId]/xp/route.ts @@ -1,7 +1,8 @@ import type { NextRequest } from 'next/server'; import { NextResponse } from 'next/server'; +import { getToken } from 'next-auth/jwt'; import { - authorizeGuildAdmin, + authorizeGuildModerator, buildUpstreamUrl, getBotApiConfig, proxyToBotApi, @@ -35,7 +36,7 @@ export async function POST( return NextResponse.json({ error: 'Missing guildId or userId' }, { status: 400 }); } - const authError = await authorizeGuildAdmin(request, guildId, LOG_PREFIX); + const authError = await authorizeGuildModerator(request, guildId, LOG_PREFIX); if (authError) return authError; const apiConfig = getBotApiConfig(LOG_PREFIX); @@ -102,9 +103,19 @@ export async function POST( ); if (upstreamUrl instanceof NextResponse) return upstreamUrl; + const token = await getToken({ req: request }); + const requesterId = + typeof token?.id === 'string' ? token.id : typeof token?.sub === 'string' ? token.sub : null; + if (!requesterId) { + return NextResponse.json({ error: 'Unable to determine Discord user id' }, { status: 401 }); + } + return proxyToBotApi(upstreamUrl, apiConfig.secret, LOG_PREFIX, 'Failed to adjust XP', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { + 'Content-Type': 'application/json', + 'x-discord-user-id': requesterId, + }, body, }); } diff --git a/web/src/app/api/guilds/[guildId]/members/export/route.ts b/web/src/app/api/guilds/[guildId]/members/export/route.ts index 6f882d800..e34cb7f1a 100644 --- a/web/src/app/api/guilds/[guildId]/members/export/route.ts +++ b/web/src/app/api/guilds/[guildId]/members/export/route.ts @@ -1,6 +1,6 @@ import type { NextRequest } from 'next/server'; import { NextResponse } from 'next/server'; -import { authorizeGuildAdmin, buildUpstreamUrl, getBotApiConfig } from '@/lib/bot-api-proxy'; +import { authorizeGuildModerator, buildUpstreamUrl, getBotApiConfig } from '@/lib/bot-api-proxy'; import { logger } from '@/lib/logger'; export const dynamic = 'force-dynamic'; @@ -11,7 +11,7 @@ const REQUEST_TIMEOUT_MS = 30_000; // CSV can take longer for large guilds /** * Proxy the guild members CSV export from the bot API and stream the resulting CSV back to the client. * - * Validates the route parameter, enforces guild-admin authorization, forwards the upstream export request with a timeout, and returns the upstream CSV body with appropriate `Content-Type` and `Content-Disposition`. On failure returns a JSON error response with an appropriate HTTP status (e.g., 400 for missing guildId, the upstream status for upstream errors, 504 for timeouts, or 500 for internal failures). + * Validates the route parameter, enforces guild-moderator authorization, forwards the upstream export request with a timeout, and returns the upstream CSV body with appropriate `Content-Type` and `Content-Disposition`. On failure returns a JSON error response with an appropriate HTTP status (e.g., 400 for missing guildId, the upstream status for upstream errors, 504 for timeouts, or 500 for internal failures). * * @returns A NextResponse containing the streamed CSV on success; on error a JSON response describing the failure with the corresponding HTTP status. */ @@ -24,7 +24,7 @@ export async function GET( return NextResponse.json({ error: 'Missing guildId' }, { status: 400 }); } - const authError = await authorizeGuildAdmin(request, guildId, LOG_PREFIX); + const authError = await authorizeGuildModerator(request, guildId, LOG_PREFIX); if (authError) return authError; const apiConfig = getBotApiConfig(LOG_PREFIX); diff --git a/web/src/app/api/guilds/[guildId]/members/route.ts b/web/src/app/api/guilds/[guildId]/members/route.ts index e8f50e7ac..03ed917a6 100644 --- a/web/src/app/api/guilds/[guildId]/members/route.ts +++ b/web/src/app/api/guilds/[guildId]/members/route.ts @@ -1,7 +1,7 @@ import type { NextRequest } from 'next/server'; import { NextResponse } from 'next/server'; import { - authorizeGuildAdmin, + authorizeGuildModerator, buildUpstreamUrl, getBotApiConfig, proxyToBotApi, @@ -14,7 +14,7 @@ const LOG_PREFIX = '[api/guilds/:guildId/members]'; /** * Proxy guild member list requests to the bot API, enriching and forwarding query parameters. * - * Validates presence of `guildId` and that the requester is a guild admin, forwards `limit`, `after`, `search`, `sort`, and `order` query parameters to the upstream `/guilds/{guildId}/members` path, and proxies the response from the bot API. + * Validates presence of `guildId` and that the requester is a guild moderator, forwards `limit`, `after`, `search`, `sort`, and `order` query parameters to the upstream `/guilds/{guildId}/members` path, and proxies the response from the bot API. * * @returns A NextResponse from the proxy call or an error NextResponse (e.g., 400 when `guildId` is missing, an authorization error response, or an upstream configuration/resolution error). */ @@ -27,7 +27,7 @@ export async function GET( return NextResponse.json({ error: 'Missing guildId' }, { status: 400 }); } - const authError = await authorizeGuildAdmin(request, guildId, LOG_PREFIX); + const authError = await authorizeGuildModerator(request, guildId, LOG_PREFIX); if (authError) return authError; const apiConfig = getBotApiConfig(LOG_PREFIX); diff --git a/web/src/app/api/guilds/[guildId]/tickets/[ticketId]/route.ts b/web/src/app/api/guilds/[guildId]/tickets/[ticketId]/route.ts index eb5939722..00989e784 100644 --- a/web/src/app/api/guilds/[guildId]/tickets/[ticketId]/route.ts +++ b/web/src/app/api/guilds/[guildId]/tickets/[ticketId]/route.ts @@ -1,7 +1,7 @@ import type { NextRequest } from 'next/server'; import { NextResponse } from 'next/server'; import { - authorizeGuildAdmin, + authorizeGuildModerator, buildUpstreamUrl, getBotApiConfig, proxyToBotApi, @@ -20,7 +20,7 @@ export async function GET( return NextResponse.json({ error: 'Missing guildId or ticketId' }, { status: 400 }); } - const authError = await authorizeGuildAdmin(request, guildId, LOG_PREFIX); + const authError = await authorizeGuildModerator(request, guildId, LOG_PREFIX); if (authError) return authError; const config = getBotApiConfig(LOG_PREFIX); diff --git a/web/src/app/api/guilds/[guildId]/tickets/route.ts b/web/src/app/api/guilds/[guildId]/tickets/route.ts index f6fd5f3e0..1cabe63e4 100644 --- a/web/src/app/api/guilds/[guildId]/tickets/route.ts +++ b/web/src/app/api/guilds/[guildId]/tickets/route.ts @@ -1,7 +1,7 @@ import type { NextRequest } from 'next/server'; import { NextResponse } from 'next/server'; import { - authorizeGuildAdmin, + authorizeGuildModerator, buildUpstreamUrl, getBotApiConfig, proxyToBotApi, @@ -20,7 +20,7 @@ export async function GET( return NextResponse.json({ error: 'Missing guildId' }, { status: 400 }); } - const authError = await authorizeGuildAdmin(request, guildId, LOG_PREFIX); + const authError = await authorizeGuildModerator(request, guildId, LOG_PREFIX); if (authError) return authError; const config = getBotApiConfig(LOG_PREFIX); diff --git a/web/src/app/api/guilds/[guildId]/tickets/stats/route.ts b/web/src/app/api/guilds/[guildId]/tickets/stats/route.ts index e5bb1052f..ba415eb0b 100644 --- a/web/src/app/api/guilds/[guildId]/tickets/stats/route.ts +++ b/web/src/app/api/guilds/[guildId]/tickets/stats/route.ts @@ -1,7 +1,7 @@ import type { NextRequest } from 'next/server'; import { NextResponse } from 'next/server'; import { - authorizeGuildAdmin, + authorizeGuildModerator, buildUpstreamUrl, getBotApiConfig, proxyToBotApi, @@ -20,7 +20,7 @@ export async function GET( return NextResponse.json({ error: 'Missing guildId' }, { status: 400 }); } - const authError = await authorizeGuildAdmin(request, guildId, LOG_PREFIX); + const authError = await authorizeGuildModerator(request, guildId, LOG_PREFIX); if (authError) return authError; const config = getBotApiConfig(LOG_PREFIX); diff --git a/web/src/app/api/guilds/route.ts b/web/src/app/api/guilds/route.ts index e0b541fc1..82a545607 100644 --- a/web/src/app/api/guilds/route.ts +++ b/web/src/app/api/guilds/route.ts @@ -1,6 +1,7 @@ import type { NextRequest } from 'next/server'; import { NextResponse } from 'next/server'; import { getToken } from 'next-auth/jwt'; +import { getBotApiBaseUrl } from '@/lib/bot-api'; import { getMutualGuilds } from '@/lib/discord.server'; import { logger } from '@/lib/logger'; @@ -8,6 +9,82 @@ export const dynamic = 'force-dynamic'; /** Request timeout for the guilds endpoint (10 seconds). */ const REQUEST_TIMEOUT_MS = 10_000; +const VALID_ACCESS_LEVELS = new Set(['admin', 'moderator', 'viewer', 'bot-owner']); +const MAX_ACCESS_LOOKUP_GUILDS = 100; + +async function applyAccessLevels( + guilds: Awaited>, + userId: string, + signal: AbortSignal, +) { + const botApiBaseUrl = getBotApiBaseUrl(); + const botApiSecret = process.env.BOT_API_SECRET; + + if (!botApiBaseUrl || !botApiSecret || guilds.length === 0) { + return guilds; + } + + const botGuildIds = guilds.filter((guild) => guild.botPresent).map((guild) => guild.id); + if (botGuildIds.length === 0) { + return guilds; + } + + try { + const accessMap = new Map(); + + for (let start = 0; start < botGuildIds.length; start += MAX_ACCESS_LOOKUP_GUILDS) { + const guildIdChunk = botGuildIds.slice(start, start + MAX_ACCESS_LOOKUP_GUILDS); + const url = new URL(`${botApiBaseUrl}/guilds/access`); + url.searchParams.set('userId', userId); + url.searchParams.set('guildIds', guildIdChunk.join(',')); + + const response = await fetch(url.toString(), { + headers: { + 'x-api-secret': botApiSecret, + }, + signal, + cache: 'no-store', + }); + + if (!response.ok) { + logger.warn('[api/guilds] Failed to fetch bot access levels', { + status: response.status, + statusText: response.statusText, + guildCount: guildIdChunk.length, + }); + return guilds; + } + + const accessEntries: unknown = await response.json(); + if (!Array.isArray(accessEntries)) { + return guilds; + } + + for (const entry of accessEntries) { + if ( + typeof entry === 'object' && + entry !== null && + typeof (entry as { id?: unknown }).id === 'string' && + typeof (entry as { access?: unknown }).access === 'string' && + VALID_ACCESS_LEVELS.has((entry as { access: string }).access) + ) { + accessMap.set( + (entry as { id: string }).id, + (entry as { access: 'admin' | 'moderator' | 'viewer' | 'bot-owner' }).access, + ); + } + } + } + + return guilds.map((guild) => ({ + ...guild, + access: accessMap.get(guild.id) ?? guild.access, + })); + } catch (error) { + logger.warn('[api/guilds] Failed to augment guild access levels', error); + return guilds; + } +} export async function GET(request: NextRequest) { const token = await getToken({ req: request }); @@ -24,7 +101,10 @@ export async function GET(request: NextRequest) { try { const signal = AbortSignal.timeout(REQUEST_TIMEOUT_MS); const guilds = await getMutualGuilds(token.accessToken as string, signal); - return NextResponse.json(guilds); + const userId = + typeof token.id === 'string' ? token.id : typeof token.sub === 'string' ? token.sub : ''; + const guildsWithAccess = userId ? await applyAccessLevels(guilds, userId, signal) : guilds; + return NextResponse.json(guildsWithAccess); } catch (error) { logger.error('[api/guilds] Failed to fetch guilds:', error); return NextResponse.json({ error: 'Failed to fetch guilds' }, { status: 500 }); diff --git a/web/src/app/api/moderation/cases/[id]/route.ts b/web/src/app/api/moderation/cases/[id]/route.ts index be522b07d..377d185dc 100644 --- a/web/src/app/api/moderation/cases/[id]/route.ts +++ b/web/src/app/api/moderation/cases/[id]/route.ts @@ -1,7 +1,7 @@ import type { NextRequest } from 'next/server'; import { NextResponse } from 'next/server'; import { - authorizeGuildAdmin, + authorizeGuildModerator, buildUpstreamUrl, getBotApiConfig, proxyToBotApi, @@ -25,7 +25,7 @@ export async function GET( return NextResponse.json({ error: 'guildId is required' }, { status: 400 }); } - const authError = await authorizeGuildAdmin(request, guildId, LOG_PREFIX); + const authError = await authorizeGuildModerator(request, guildId, LOG_PREFIX); if (authError) return authError; const config = getBotApiConfig(LOG_PREFIX); diff --git a/web/src/app/api/moderation/cases/route.ts b/web/src/app/api/moderation/cases/route.ts index 40fa42103..c740d0477 100644 --- a/web/src/app/api/moderation/cases/route.ts +++ b/web/src/app/api/moderation/cases/route.ts @@ -1,7 +1,7 @@ import type { NextRequest } from 'next/server'; import { NextResponse } from 'next/server'; import { - authorizeGuildAdmin, + authorizeGuildModerator, buildUpstreamUrl, getBotApiConfig, proxyToBotApi, @@ -15,7 +15,7 @@ const ALLOWED_PARAMS = ['guildId', 'targetId', 'action', 'page', 'limit', 'order /** * GET /api/moderation/cases * Proxies to bot API GET /api/v1/moderation/cases - * Requires guildId query param and admin authorization. + * Requires guildId query param and moderator authorization. */ export async function GET(request: NextRequest): Promise { const guildId = request.nextUrl.searchParams.get('guildId'); @@ -23,7 +23,7 @@ export async function GET(request: NextRequest): Promise { return NextResponse.json({ error: 'guildId is required' }, { status: 400 }); } - const authError = await authorizeGuildAdmin(request, guildId, LOG_PREFIX); + const authError = await authorizeGuildModerator(request, guildId, LOG_PREFIX); if (authError) return authError; const config = getBotApiConfig(LOG_PREFIX); diff --git a/web/src/app/api/moderation/stats/route.ts b/web/src/app/api/moderation/stats/route.ts index 388a2f9bf..87b429b01 100644 --- a/web/src/app/api/moderation/stats/route.ts +++ b/web/src/app/api/moderation/stats/route.ts @@ -1,7 +1,7 @@ import type { NextRequest } from 'next/server'; import { NextResponse } from 'next/server'; import { - authorizeGuildAdmin, + authorizeGuildModerator, buildUpstreamUrl, getBotApiConfig, proxyToBotApi, @@ -14,7 +14,7 @@ const LOG_PREFIX = '[api/moderation/stats]'; /** * GET /api/moderation/stats * Proxies to bot API GET /api/v1/moderation/stats - * Requires guildId query param and admin authorization. + * Requires guildId query param and moderator authorization. */ export async function GET(request: NextRequest): Promise { const guildId = request.nextUrl.searchParams.get('guildId'); @@ -22,7 +22,7 @@ export async function GET(request: NextRequest): Promise { return NextResponse.json({ error: 'guildId is required' }, { status: 400 }); } - const authError = await authorizeGuildAdmin(request, guildId, LOG_PREFIX); + const authError = await authorizeGuildModerator(request, guildId, LOG_PREFIX); if (authError) return authError; const config = getBotApiConfig(LOG_PREFIX); diff --git a/web/src/app/api/moderation/user/[userId]/history/route.ts b/web/src/app/api/moderation/user/[userId]/history/route.ts index beb5668ae..9b2dbce57 100644 --- a/web/src/app/api/moderation/user/[userId]/history/route.ts +++ b/web/src/app/api/moderation/user/[userId]/history/route.ts @@ -1,7 +1,7 @@ import type { NextRequest } from 'next/server'; import { NextResponse } from 'next/server'; import { - authorizeGuildAdmin, + authorizeGuildModerator, buildUpstreamUrl, getBotApiConfig, proxyToBotApi, @@ -25,7 +25,7 @@ export async function GET( return NextResponse.json({ error: 'guildId is required' }, { status: 400 }); } - const authError = await authorizeGuildAdmin(request, guildId, LOG_PREFIX); + const authError = await authorizeGuildModerator(request, guildId, LOG_PREFIX); if (authError) return authError; const config = getBotApiConfig(LOG_PREFIX); diff --git a/web/src/components/dashboard/config-editor.tsx b/web/src/components/dashboard/config-editor.tsx index 0c521238f..c58c379b1 100644 --- a/web/src/components/dashboard/config-editor.tsx +++ b/web/src/components/dashboard/config-editor.tsx @@ -1,182 +1,18 @@ 'use client'; -import { useEffect, useState } from 'react'; -import { SELECTED_GUILD_KEY } from '@/lib/guild-selection'; -import type { GuildConfig } from './config-editor-utils'; -import { isGuildConfig } from './config-editor-utils'; -import { DiscardChangesButton } from './reset-defaults-button'; -import { SystemPromptEditor } from './system-prompt-editor'; - -function getSelectedGuildId(): string { - try { - return localStorage.getItem(SELECTED_GUILD_KEY) ?? ''; - } catch { - return ''; - } -} - +import { AiAutomationCategory } from '@/components/dashboard/config-categories/ai-automation'; +import { OnboardingGrowthCategory } from '@/components/dashboard/config-categories/onboarding-growth'; +import { ConfigLayoutShell } from '@/components/dashboard/config-layout-shell'; + +/** + * Backward-compatible config editor entry point used by older tests. + * Renders the settings shell with representative category content. + */ export function ConfigEditor() { - const [draftConfig, setDraftConfig] = useState(null); - const [savedConfig, setSavedConfig] = useState(null); - const [loading, setLoading] = useState(true); - const [saving, setSaving] = useState(false); - const [error, setError] = useState(null); - const [hasChanges, setHasChanges] = useState(false); - - useEffect(() => { - const guildId = getSelectedGuildId(); - if (!guildId) { - setLoading(false); - setDraftConfig({}); - return; - } - - let cancelled = false; - - async function loadConfig() { - setLoading(true); - setError(null); - - try { - const res = await fetch(`/api/guilds/${encodeURIComponent(guildId)}/config`, { - cache: 'no-store', - }); - - if (!res.ok) { - throw new Error(`HTTP ${res.status}`); - } - - const data: unknown = await res.json(); - if (!isGuildConfig(data)) { - throw new Error('Invalid config response'); - } - - if (!cancelled) { - setDraftConfig(data); - setSavedConfig(data); - setHasChanges(false); - } - } catch (err) { - if (!cancelled) { - setError((err as Error).message || 'Failed to load config'); - } - } finally { - if (!cancelled) { - setLoading(false); - } - } - } - - void loadConfig(); - - return () => { - cancelled = true; - }; - }, []); - - if (loading) { - return
Loading configuration…
; - } - - if (error) { - return
{error}
; - } - - async function saveChanges() { - const guildId = getSelectedGuildId(); - if (!guildId || !draftConfig) { - return; - } - - setSaving(true); - setError(null); - - try { - const res = await fetch(`/api/guilds/${encodeURIComponent(guildId)}/config`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - path: 'ai.systemPrompt', - value: draftConfig.ai?.systemPrompt ?? '', - }), - }); - - if (!res.ok) { - throw new Error(`HTTP ${res.status}`); - } - - const updatedSection: unknown = await res.json(); - const nextDraftConfig = { - ...(draftConfig ?? {}), - ai: updatedSection && typeof updatedSection === 'object' ? updatedSection : draftConfig.ai, - } satisfies GuildConfig; - - setDraftConfig(nextDraftConfig); - setSavedConfig(nextDraftConfig); - setHasChanges(false); - } catch (err) { - setError((err as Error).message || 'Failed to save config'); - } finally { - setSaving(false); - } - } - - function discardChanges() { - if (!savedConfig) { - return; - } - - setDraftConfig(structuredClone(savedConfig)); - setHasChanges(false); - } - return ( -
-
-
-

Bot Configuration

-

Manage guild configuration sections.

-
-
- - -
-
- -
-

AI Chat

- { - setDraftConfig((prev) => ({ - ...(prev ?? {}), - ai: { - ...(prev?.ai ?? {}), - systemPrompt, - }, - })); - setHasChanges(true); - }} - /> -
- -
-

Welcome Messages

-

- Welcome message configuration is available in the settings workspace. -

-
-
+ + + + ); } diff --git a/web/src/components/layout/dashboard-shell.tsx b/web/src/components/layout/dashboard-shell.tsx index 45b0a4f47..3b3b58480 100644 --- a/web/src/components/layout/dashboard-shell.tsx +++ b/web/src/components/layout/dashboard-shell.tsx @@ -1,5 +1,6 @@ import type { ReactNode } from 'react'; import { DashboardTitleSync } from './dashboard-title-sync'; +import { GuildDirectoryProvider } from './guild-directory-context'; import { Header } from './header'; import { ServerSelector } from './server-selector'; import { Sidebar } from './sidebar'; @@ -15,28 +16,30 @@ interface DashboardShellProps { */ export function DashboardShell({ children }: DashboardShellProps) { return ( -
- -
+ +
+ +
-
- {/* Desktop sidebar */} - +
+ {/* Desktop sidebar */} + - {/* Main content */} -
-
-
{children}
-
-
+ {/* Main content */} +
+
+
{children}
+
+
+
-
+
); } diff --git a/web/src/components/layout/guild-directory-context.tsx b/web/src/components/layout/guild-directory-context.tsx new file mode 100644 index 000000000..e3b1f40ae --- /dev/null +++ b/web/src/components/layout/guild-directory-context.tsx @@ -0,0 +1,101 @@ +'use client'; + +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import type { MutualGuild } from '@/types/discord'; + +interface GuildDirectoryContextValue { + error: boolean; + guilds: MutualGuild[]; + loading: boolean; + refreshGuilds: () => Promise; +} + +const GuildDirectoryContext = createContext(null); + +function isMutualGuild(value: unknown): value is MutualGuild { + return ( + typeof value === 'object' && + value !== null && + typeof (value as { id?: unknown }).id === 'string' && + typeof (value as { name?: unknown }).name === 'string' && + typeof (value as { permissions?: unknown }).permissions === 'string' && + typeof (value as { owner?: unknown }).owner === 'boolean' && + typeof (value as { botPresent?: unknown }).botPresent === 'boolean' + ); +} + +export function GuildDirectoryProvider({ children }: Readonly<{ children: React.ReactNode }>) { + const [guilds, setGuilds] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(false); + const abortControllerRef = useRef(null); + + const refreshGuilds = useCallback(async () => { + abortControllerRef.current?.abort(); + const controller = new AbortController(); + abortControllerRef.current = controller; + + setLoading(true); + setError(false); + + try { + const response = await fetch('/api/guilds', { signal: controller.signal }); + if (response.status === 401) { + globalThis.location.href = '/login'; + return; + } + if (!response.ok) { + throw new Error('Failed to fetch guilds'); + } + + const data: unknown = await response.json(); + if (!Array.isArray(data)) { + throw new TypeError('Invalid guild response'); + } + + setGuilds(data.filter(isMutualGuild)); + } catch (error) { + if (error instanceof DOMException && error.name === 'AbortError') { + return; + } + setError(true); + } finally { + if (abortControllerRef.current === controller) { + setLoading(false); + } + } + }, []); + + useEffect(() => { + refreshGuilds(); + return () => abortControllerRef.current?.abort(); + }, [refreshGuilds]); + + const value = useMemo( + () => ({ + error, + guilds, + loading, + refreshGuilds, + }), + [error, guilds, loading, refreshGuilds], + ); + + return {children}; +} + +export function useGuildDirectory() { + const context = useContext(GuildDirectoryContext); + if (!context) { + throw new Error('useGuildDirectory must be used within GuildDirectoryProvider'); + } + return context; +} diff --git a/web/src/components/layout/server-selector.tsx b/web/src/components/layout/server-selector.tsx index 7d7782dce..82e5b048f 100644 --- a/web/src/components/layout/server-selector.tsx +++ b/web/src/components/layout/server-selector.tsx @@ -3,7 +3,7 @@ import { Bot, ChevronsUpDown, ExternalLink, RefreshCw, Server } from 'lucide-react'; import Image from 'next/image'; import Link from 'next/link'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { Button } from '@/components/ui/button'; import { DropdownMenu, @@ -18,6 +18,7 @@ import { getBotInviteUrl, getGuildIconUrl } from '@/lib/discord'; import { broadcastSelectedGuild, SELECTED_GUILD_KEY } from '@/lib/guild-selection'; import { cn } from '@/lib/utils'; import type { MutualGuild } from '@/types/discord'; +import { useGuildDirectory } from './guild-directory-context'; interface ServerSelectorProps { className?: string; @@ -48,11 +49,8 @@ function GuildRow({ guild }: { guild: MutualGuild }) { } export function ServerSelector({ className }: ServerSelectorProps) { - const [guilds, setGuilds] = useState([]); const [selectedGuild, setSelectedGuild] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(false); - const abortControllerRef = useRef(null); + const { error, guilds, loading, refreshGuilds } = useGuildDirectory(); // Split guilds into manageable (mod/admin/owner) and member-only (viewer) const { manageable, memberOnly } = useMemo( @@ -82,71 +80,31 @@ export function ServerSelector({ className }: ServerSelectorProps) { broadcastSelectedGuild(guild.id); }, []); - const loadGuilds = useCallback(async () => { - // Abort any previous in-flight request before starting a new one. - abortControllerRef.current?.abort(); - const controller = new AbortController(); - abortControllerRef.current = controller; + useEffect(() => { + const manageableGuilds = guilds.filter(isGuildManageable); + if (manageableGuilds.length === 0) { + setSelectedGuild(null); + return; + } - setLoading(true); - setError(false); + let restored = false; try { - const response = await fetch('/api/guilds', { signal: controller.signal }); - if (response.status === 401) { - window.location.href = '/login'; - return; - } - if (!response.ok) throw new Error('Failed to fetch'); - const data: unknown = await response.json(); - if (!Array.isArray(data)) throw new Error('Invalid response: expected array'); - - // Runtime shape check — permissions and owner required for isGuildManageable - const fetchedGuilds = data.filter( - (g): g is MutualGuild => - typeof g === 'object' && - g !== null && - typeof (g as Record).id === 'string' && - typeof (g as Record).name === 'string' && - typeof (g as Record).permissions === 'string' && - typeof (g as Record).owner === 'boolean', - ); - setGuilds(fetchedGuilds); - - // Only manageable guilds can be selected as the active dashboard guild - const manageableGuilds = fetchedGuilds.filter(isGuildManageable); - - // Restore previously selected guild from localStorage (must be manageable) - let restored = false; - try { - const savedId = localStorage.getItem(SELECTED_GUILD_KEY); - if (savedId) { - const saved = manageableGuilds.find((g) => g.id === savedId); - if (saved) { - setSelectedGuild(saved); - restored = true; - } + const savedId = localStorage.getItem(SELECTED_GUILD_KEY); + if (savedId) { + const saved = manageableGuilds.find((guild) => guild.id === savedId); + if (saved) { + setSelectedGuild(saved); + restored = true; } - } catch { - // localStorage unavailable - } - - if (!restored && manageableGuilds.length > 0) { - selectGuild(manageableGuilds[0]); - } - } catch (err) { - if (err instanceof DOMException && err.name === 'AbortError') return; - setError(true); - } finally { - if (abortControllerRef.current === controller) { - setLoading(false); } + } catch { + // localStorage unavailable } - }, [selectGuild]); - useEffect(() => { - loadGuilds(); - return () => abortControllerRef.current?.abort(); - }, [loadGuilds]); + if (!restored) { + selectGuild(manageableGuilds[0]); + } + }, [guilds, selectGuild]); if (loading) { return ( @@ -164,7 +122,7 @@ export function ServerSelector({ className }: ServerSelectorProps) { Refresh the list and we'll try again. - @@ -269,7 +227,7 @@ export function ServerSelector({ className }: ServerSelectorProps) { ) : (
- You need mod or admin permissions to manage a server. + You need moderator, admin, or owner permissions to manage a server.
)} diff --git a/web/src/components/layout/sidebar.tsx b/web/src/components/layout/sidebar.tsx index c882fd01b..6d47b6518 100644 --- a/web/src/components/layout/sidebar.tsx +++ b/web/src/components/layout/sidebar.tsx @@ -22,7 +22,9 @@ import Link from 'next/link'; import { usePathname } from 'next/navigation'; import { type ComponentType, useEffect, useState } from 'react'; import { Separator } from '@/components/ui/separator'; +import { useGuildSelection } from '@/hooks/use-guild-selection'; import { cn } from '@/lib/utils'; +import { useGuildDirectory } from './guild-directory-context'; /** Shared shape for sidebar navigation entries */ interface NavItem { @@ -49,6 +51,10 @@ const secondaryNav: NavItem[] = [ { name: 'Settings', href: '/dashboard/settings', icon: Settings }, ]; +const moderatorPrimaryNav: NavItem[] = primaryNav.filter((item) => + ['/dashboard/moderation', '/dashboard/members', '/dashboard/tickets'].includes(item.href), +); + interface SidebarProps { className?: string; onNavClick?: () => void; @@ -98,10 +104,16 @@ function renderNavItem(item: NavItem, isActive: boolean, onNavClick?: () => void export function Sidebar({ className, onNavClick }: SidebarProps) { const pathname = usePathname(); + const guildId = useGuildSelection(); + const { guilds } = useGuildDirectory(); const isNavItemActive = (href: string) => pathname === href || (href !== '/dashboard' && pathname.startsWith(`${href}/`)); - const hasActiveSecondaryItem = secondaryNav.some((item) => isNavItemActive(item.href)); - const activeSecondaryHref = secondaryNav.find((item) => isNavItemActive(item.href))?.href ?? null; + const activeGuildAccess = guilds.find((guild) => guild.id === guildId)?.access ?? null; + const visiblePrimaryNav = activeGuildAccess === 'moderator' ? moderatorPrimaryNav : primaryNav; + const visibleSecondaryNav = activeGuildAccess === 'moderator' ? [] : secondaryNav; + const hasActiveSecondaryItem = visibleSecondaryNav.some((item) => isNavItemActive(item.href)); + const activeSecondaryHref = + visibleSecondaryNav.find((item) => isNavItemActive(item.href))?.href ?? null; const [isSecondaryOpen, setIsSecondaryOpen] = useState(hasActiveSecondaryItem); useEffect(() => { @@ -122,29 +134,35 @@ export function Sidebar({ className, onNavClick }: SidebarProps) {
- + {visibleSecondaryNav.length > 0 && } -
setIsSecondaryOpen((event.currentTarget as HTMLDetailsElement).open)} - > - - - - Extensions - - - - -
+ {visibleSecondaryNav.length > 0 && ( +
+ setIsSecondaryOpen((event.currentTarget as HTMLDetailsElement).open) + } + > + + + + Extensions + + + + +
+ )} {/* Workflow tip card */}
diff --git a/web/src/components/ui/discord-markdown-editor.tsx b/web/src/components/ui/discord-markdown-editor.tsx index f93830663..93c8f668a 100644 --- a/web/src/components/ui/discord-markdown-editor.tsx +++ b/web/src/components/ui/discord-markdown-editor.tsx @@ -66,6 +66,26 @@ interface ToolbarAction { ) => { text: string; selectionStart?: number; selectionEnd?: number; cursorPos?: number }; } +function clampEditResult( + result: ReturnType, + maxLength: number, +): ReturnType { + if (result.text.length <= maxLength) { + return result; + } + + const text = result.text.slice(0, maxLength); + const clampIndex = (index: number | undefined) => + index === undefined ? undefined : Math.min(index, maxLength); + + return { + text, + selectionStart: clampIndex(result.selectionStart), + selectionEnd: clampIndex(result.selectionEnd), + cursorPos: clampIndex(result.cursorPos), + }; +} + function renderPreviewNode(node: ChildNode, key: string): React.ReactNode { if (node.nodeType === Node.TEXT_NODE) { return node.textContent; @@ -220,7 +240,7 @@ export function DiscordMarkdownEditor({ const start = textarea.selectionStart; const end = textarea.selectionEnd; - const result = action(value, start, end); + const result = clampEditResult(action(value, start, end), maxLength); onChange(result.text); @@ -235,7 +255,7 @@ export function DiscordMarkdownEditor({ }); rafIdsRef.current.push(rafId); }, - [value, onChange], + [maxLength, onChange, value], ); const insertVariable = React.useCallback( @@ -244,7 +264,7 @@ export function DiscordMarkdownEditor({ if (!textarea) return; const cursor = textarea.selectionStart; - const result = insertAtCursor(value, cursor, `${varOpen}${varName}${varClose}`); + const result = clampEditResult(insertAtCursor(value, cursor, `{{${varName}}}`), maxLength); onChange(result.text); setShowVariables(false); @@ -258,7 +278,7 @@ export function DiscordMarkdownEditor({ }); rafIdsRef.current.push(rafId); }, - [value, onChange], + [maxLength, onChange, value], ); const handleKeyDown = React.useCallback( @@ -384,6 +404,7 @@ export function DiscordMarkdownEditor({ ref={textareaRef} value={value} onChange={(e) => onChange(e.target.value)} + maxLength={maxLength} onKeyDown={handleKeyDown} placeholder={placeholder} disabled={disabled} diff --git a/web/src/components/ui/embed-builder.tsx b/web/src/components/ui/embed-builder.tsx index 311eaf941..467dff340 100644 --- a/web/src/components/ui/embed-builder.tsx +++ b/web/src/components/ui/embed-builder.tsx @@ -341,6 +341,10 @@ function formatPreviewTimestamp(date: Date): string { }).format(date); } +function clampTextToAvailable(text: string, available: number): string { + return available <= 0 ? '' : text.slice(0, available); +} + // ── CharCount indicator ───────────────────────────────────────────── function CharCount({ current, max }: { current: number; max: number }) { @@ -542,37 +546,88 @@ function EmbedBuilder({ value, onChange, variables = [], className }: EmbedBuild } }, [value, onChange]); + const commit = React.useCallback( + (next: EmbedConfig) => { + if (getTotalCharCount(next) <= CHAR_LIMITS.total) { + onChange(next); + } + }, + [onChange], + ); + + const commitFieldPatch = React.useCallback( + (index: number, patch: Partial) => { + const fields = [...value.fields]; + const currentField = fields[index]; + if (!currentField) { + return; + } + + const baseConfig = { + ...value, + fields: fields.map((field, fieldIndex) => + fieldIndex === index + ? { + ...field, + ...(patch.name !== undefined ? { name: '' } : {}), + ...(patch.value !== undefined ? { value: '' } : {}), + } + : field, + ), + }; + + const baseTotal = getTotalCharCount(baseConfig); + let available = Math.max(CHAR_LIMITS.total - baseTotal, 0); + const nextField = { ...currentField }; + + if (patch.name !== undefined) { + nextField.name = clampTextToAvailable(patch.name, available); + available -= nextField.name.length; + } + + if (patch.value !== undefined) { + nextField.value = clampTextToAvailable(patch.value, available); + } + + if (patch.inline !== undefined) { + nextField.inline = patch.inline; + } + + fields[index] = nextField; + commit({ ...value, fields }); + }, + [commit, value], + ); + const update = React.useCallback( (patch: Partial) => { - onChange({ ...value, ...patch }); + commit({ ...value, ...patch }); }, - [value, onChange], + [commit, value], ); const updateField = React.useCallback( (index: number, patch: Partial) => { - const fields = [...value.fields]; - fields[index] = { ...fields[index], ...patch }; - onChange({ ...value, fields }); + commitFieldPatch(index, patch); }, - [value, onChange], + [commitFieldPatch], ); const addField = React.useCallback(() => { - onChange({ + commit({ ...value, fields: [...value.fields, createEmptyField()], }); - }, [value, onChange]); + }, [commit, value]); const removeField = React.useCallback( (index: number) => { - onChange({ + commit({ ...value, fields: value.fields.filter((_, i) => i !== index), }); }, - [value, onChange], + [commit, value], ); const moveField = React.useCallback( @@ -581,9 +636,9 @@ function EmbedBuilder({ value, onChange, variables = [], className }: EmbedBuild const target = direction === 'up' ? index - 1 : index + 1; if (target < 0 || target >= fields.length) return; [fields[index], fields[target]] = [fields[target], fields[index]]; - onChange({ ...value, fields }); + commit({ ...value, fields }); }, - [value, onChange], + [commit, value], ); const totalChars = getTotalCharCount(value); diff --git a/web/src/hooks/use-guild-role.ts b/web/src/hooks/use-guild-role.ts index 68c6d35c9..a2ea2529d 100644 --- a/web/src/hooks/use-guild-role.ts +++ b/web/src/hooks/use-guild-role.ts @@ -12,8 +12,8 @@ const MODERATE_MEMBERS = 0x10000000000n; // 1 << 40 /** * Dashboard role hierarchy (highest to lowest access). * owner — guild owner - * admin — ADMINISTRATOR or MANAGE_GUILD permission - * moderator — KICK_MEMBERS, BAN_MEMBERS, or MODERATE_MEMBERS permission + * admin — ADMINISTRATOR permission + * moderator — MANAGE_GUILD, KICK_MEMBERS, BAN_MEMBERS, or MODERATE_MEMBERS permission * viewer — member with no elevated permissions */ export type GuildDashboardRole = 'owner' | 'admin' | 'moderator' | 'viewer'; @@ -26,7 +26,11 @@ export type GuildDashboardRole = 'owner' | 'admin' | 'moderator' | 'viewer'; * @returns The user's dashboard role in that guild */ export function getGuildDashboardRole(guild: MutualGuild): GuildDashboardRole { - if (guild.owner) return 'owner'; + if (guild.access === 'owner' || guild.owner) return 'owner'; + if (guild.access === 'bot-owner') return 'admin'; + if (guild.access === 'admin') return 'admin'; + if (guild.access === 'moderator') return 'moderator'; + if (guild.access === 'viewer') return 'viewer'; let perms: bigint; try { @@ -35,10 +39,14 @@ export function getGuildDashboardRole(guild: MutualGuild): GuildDashboardRole { return 'viewer'; } - if ((perms & ADMINISTRATOR) === ADMINISTRATOR || (perms & MANAGE_GUILD) === MANAGE_GUILD) { + if ((perms & ADMINISTRATOR) === ADMINISTRATOR) { return 'admin'; } + if ((perms & MANAGE_GUILD) === MANAGE_GUILD) { + return 'moderator'; + } + if ( (perms & KICK_MEMBERS) === KICK_MEMBERS || (perms & BAN_MEMBERS) === BAN_MEMBERS || @@ -56,5 +64,6 @@ export function getGuildDashboardRole(guild: MutualGuild): GuildDashboardRole { * Non-manageable roles: viewer (member-only). */ export function isGuildManageable(guild: MutualGuild): boolean { - return getGuildDashboardRole(guild) !== 'viewer'; + const role = getGuildDashboardRole(guild); + return role === 'owner' || role === 'admin' || role === 'moderator'; } diff --git a/web/src/lib/bot-api-proxy.ts b/web/src/lib/bot-api-proxy.ts index 4ff68c142..88f03ab4e 100644 --- a/web/src/lib/bot-api-proxy.ts +++ b/web/src/lib/bot-api-proxy.ts @@ -7,6 +7,24 @@ import { logger } from '@/lib/logger'; const REQUEST_TIMEOUT_MS = 10_000; const ADMINISTRATOR_PERMISSION = 0x8n; +const MANAGE_GUILD_PERMISSION = 0x20n; +const KICK_MEMBERS_PERMISSION = 0x2n; +const BAN_MEMBERS_PERMISSION = 0x4n; +const MODERATE_MEMBERS_PERMISSION = 0x10000000000n; + +export type GuildAccessLevel = 'viewer' | 'moderator' | 'admin' | 'bot-owner'; +type RequiredGuildAccess = 'moderator' | 'admin'; +type AuthToken = { + accessToken: string; + id?: string; + sub?: string; +}; +const GUILD_ACCESS_LEVELS = new Set([ + 'viewer', + 'moderator', + 'admin', + 'bot-owner', +]); /** * Determines whether a Discord permission bitfield includes the administrator permission. @@ -22,22 +40,106 @@ export function hasAdministratorPermission(permissions: string): boolean { } } -/** - * Verify that the incoming request is from the owner or an administrator of the specified guild. - * - * @param request - The incoming NextRequest containing the user's session/token. - * @param guildId - The Discord guild ID to authorize against. - * @param logPrefix - Prefix used when logging contextual error messages. - * @returns `null` if the requester is authorized; a `NextResponse` containing an error JSON otherwise. - * Possible responses: - * - 401 Unauthorized when the access token is missing or expired. - * - 502 Bad Gateway when mutual guilds cannot be verified. - * - 403 Forbidden when the user is neither the guild owner nor has administrator permission. - */ -export async function authorizeGuildAdmin( +export function hasModeratorPermission(permissions: string): boolean { + try { + const bitfield = BigInt(permissions); + return ( + (bitfield & MANAGE_GUILD_PERMISSION) === MANAGE_GUILD_PERMISSION || + (bitfield & KICK_MEMBERS_PERMISSION) === KICK_MEMBERS_PERMISSION || + (bitfield & BAN_MEMBERS_PERMISSION) === BAN_MEMBERS_PERMISSION || + (bitfield & MODERATE_MEMBERS_PERMISSION) === MODERATE_MEMBERS_PERMISSION + ); + } catch { + return false; + } +} + +function accessSatisfiesRequirement( + access: GuildAccessLevel, + required: RequiredGuildAccess, +): boolean { + if (access === 'bot-owner' || access === 'admin') return true; + return required === 'moderator' && access === 'moderator'; +} + +function getFallbackGuildAccess(guild: { owner?: boolean; permissions: string }): GuildAccessLevel { + if (guild.owner) return 'admin'; + if (hasAdministratorPermission(guild.permissions)) return 'admin'; + if (hasModeratorPermission(guild.permissions)) return 'moderator'; + return 'viewer'; +} + +function getUserIdFromToken(token: AuthToken): string { + if (typeof token.id === 'string') return token.id; + if (typeof token.sub === 'string') return token.sub; + return ''; +} + +async function resolveGuildAccess( + token: AuthToken, + guildId: string, + logPrefix: string, + signal: AbortSignal, +): Promise<{ access: GuildAccessLevel; present: boolean }> { + const mutualGuilds = await getMutualGuilds(token.accessToken, signal); + const targetGuild = mutualGuilds.find((guild) => guild.id === guildId); + + if (!targetGuild) { + return { access: 'viewer', present: false }; + } + + const fallbackAccess = getFallbackGuildAccess(targetGuild); + const userId = getUserIdFromToken(token); + const botApiBaseUrl = getBotApiBaseUrl(); + const botApiSecret = process.env.BOT_API_SECRET; + + if (!userId || !botApiBaseUrl || !botApiSecret) { + return { access: fallbackAccess, present: true }; + } + + try { + const url = new URL(`${botApiBaseUrl}/guilds/access`); + url.searchParams.set('userId', userId); + url.searchParams.set('guildIds', guildId); + + const response = await fetch(url.toString(), { + headers: { + 'x-api-secret': botApiSecret, + }, + signal, + cache: 'no-store', + }); + + if (!response.ok) { + return { access: fallbackAccess, present: true }; + } + + const entries: unknown = await response.json(); + if (!Array.isArray(entries)) { + return { access: fallbackAccess, present: true }; + } + + const entry = entries.find( + (item): item is { id: string; access: GuildAccessLevel } => + typeof item === 'object' && + item !== null && + (item as { id?: unknown }).id === guildId && + typeof (item as { access?: unknown }).access === 'string' && + GUILD_ACCESS_LEVELS.has((item as { access: GuildAccessLevel }).access), + ); + + return { access: entry?.access ?? fallbackAccess, present: true }; + } catch (error) { + logger.error(`${logPrefix} Failed to resolve guild access:`, error); + return { access: fallbackAccess, present: true }; + } +} + +async function authorizeGuildAccess( request: NextRequest, guildId: string, logPrefix: string, + requiredAccess: RequiredGuildAccess, ): Promise { const token = await getToken({ req: request }); @@ -49,23 +151,71 @@ export async function authorizeGuildAdmin( return NextResponse.json({ error: 'Token expired. Please sign in again.' }, { status: 401 }); } - let mutualGuilds: Awaited>; + const authToken: AuthToken = { + accessToken: token.accessToken, + id: typeof token.id === 'string' ? token.id : undefined, + sub: typeof token.sub === 'string' ? token.sub : undefined, + }; + + let resolved: Awaited>; + const controller = new AbortController(); + const timeout = setTimeout(() => { + controller.abort(new DOMException('Timed out', 'TimeoutError')); + }, REQUEST_TIMEOUT_MS); try { - mutualGuilds = await getMutualGuilds( - token.accessToken, - AbortSignal.timeout(REQUEST_TIMEOUT_MS), - ); + resolved = await resolveGuildAccess(authToken, guildId, logPrefix, controller.signal); } catch (error) { logger.error(`${logPrefix} Failed to verify guild permissions:`, error); return NextResponse.json({ error: 'Failed to verify guild permissions' }, { status: 502 }); + } finally { + clearTimeout(timeout); } - const targetGuild = mutualGuilds.find((guild) => guild.id === guildId); - if (!targetGuild || !(targetGuild.owner || hasAdministratorPermission(targetGuild.permissions))) { + if (!resolved.present || !accessSatisfiesRequirement(resolved.access, requiredAccess)) { return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); } - return null; // authorized + return null; +} + +/** + * Verify that the incoming request has admin-or-higher dashboard access for the specified guild. + * + * @param request - The incoming NextRequest containing the user's session/token. + * @param guildId - The Discord guild ID to authorize against. + * @param logPrefix - Prefix used when logging contextual error messages. + * @returns `null` if the requester is authorized; a `NextResponse` containing an error JSON otherwise. + * Possible responses: + * - 401 Unauthorized when the access token is missing or expired. + * - 502 Bad Gateway when mutual guilds cannot be verified. + * - 403 Forbidden when the user does not have admin-or-higher dashboard access. + */ +export async function authorizeGuildAdmin( + request: NextRequest, + guildId: string, + logPrefix: string, +): Promise { + return authorizeGuildAccess(request, guildId, logPrefix, 'admin'); +} + +/** + * Verify that the incoming request has moderator-or-higher dashboard access for the specified guild. + * + * @param request - The incoming NextRequest containing the user's session/token. + * @param guildId - The Discord guild ID to authorize against. + * @param logPrefix - Prefix used when logging contextual error messages. + * @returns `null` if the requester is authorized; a `NextResponse` containing an error JSON otherwise. + * Possible responses: + * - 401 Unauthorized when the access token is missing or expired. + * - 502 Bad Gateway when mutual guilds cannot be verified. + * - 403 Forbidden when the user does not have moderator-or-higher dashboard access. + */ +export async function authorizeGuildModerator( + request: NextRequest, + guildId: string, + logPrefix: string, +): Promise { + return authorizeGuildAccess(request, guildId, logPrefix, 'moderator'); } export interface BotApiConfig { diff --git a/web/src/lib/discord.server.ts b/web/src/lib/discord.server.ts index a1ad1753b..f4cf32925 100644 --- a/web/src/lib/discord.server.ts +++ b/web/src/lib/discord.server.ts @@ -9,16 +9,54 @@ const DISCORD_API_BASE = 'https://discord.com/api/v10'; /** Maximum number of retry attempts for rate-limited requests. */ const MAX_RETRIES = 3; +/** Default maximum delay we'll honor from a single retry-after header. */ +const DEFAULT_MAX_RETRY_DELAY_MS = 5_000; + +/** Default total time budget to spend sleeping across all retries. */ +const DEFAULT_TOTAL_RETRY_BUDGET_MS = 8_000; + /** Discord returns at most 200 guilds per page. */ const GUILDS_PER_PAGE = 200; +interface FetchWithRateLimitOptions extends RequestInit { + rateLimit?: { + maxRetries?: number; + maxRetryDelayMs?: number; + totalRetryBudgetMs?: number; + }; +} + +function parseRetryAfterMs(response: Response): number { + const retryAfter = response.headers.get('retry-after'); + const resetAfter = response.headers.get('x-ratelimit-reset-after'); + + const parseSeconds = (value: string | null): number | null => { + if (!value) { + return null; + } + const parsed = Number.parseFloat(value); + return Number.isFinite(parsed) && parsed > 0 ? parsed * 1000 : null; + }; + + return parseSeconds(retryAfter) ?? parseSeconds(resetAfter) ?? 1000; +} + /** * Fetch wrapper with basic rate limit retry logic. * When Discord returns 429 Too Many Requests, waits for the indicated * retry-after duration and retries up to MAX_RETRIES times. */ -export async function fetchWithRateLimit(url: string, init?: RequestInit): Promise { - for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { +export async function fetchWithRateLimit( + url: string, + init?: FetchWithRateLimitOptions, +): Promise { + const maxRetries = init?.rateLimit?.maxRetries ?? MAX_RETRIES; + const maxRetryDelayMs = init?.rateLimit?.maxRetryDelayMs ?? DEFAULT_MAX_RETRY_DELAY_MS; + const totalRetryBudgetMs = init?.rateLimit?.totalRetryBudgetMs ?? DEFAULT_TOTAL_RETRY_BUDGET_MS; + let totalWaitMs = 0; + const maxAttempts = maxRetries + 1; + + for (let attempt = 0; attempt <= maxRetries; attempt++) { const response = await fetch(url, init); if (response.status !== 429) { @@ -26,16 +64,20 @@ export async function fetchWithRateLimit(url: string, init?: RequestInit): Promi } // Rate limited — parse retry-after header (seconds) - const retryAfter = response.headers.get('retry-after'); - const parsed = retryAfter ? Number.parseFloat(retryAfter) : NaN; - const waitMs = Number.isFinite(parsed) && parsed > 0 ? parsed * 1000 : 1000; + const waitMs = parseRetryAfterMs(response); + const remainingBudgetMs = totalRetryBudgetMs - totalWaitMs; - if (attempt === MAX_RETRIES) { - return response; // Out of retries, return the 429 as-is + if (attempt === maxRetries || waitMs > maxRetryDelayMs || waitMs > remainingBudgetMs) { + logger.warn( + `[discord] Rate limited on ${url}, not retrying after ${waitMs}ms ` + + `(attempt ${attempt + 1}/${maxAttempts}, remaining budget ${Math.max(remainingBudgetMs, 0)}ms)`, + ); + return response; } logger.warn( - `[discord] Rate limited on ${url}, retrying in ${waitMs}ms (attempt ${attempt + 1}/${MAX_RETRIES})`, + `[discord] Rate limited on ${url}, retrying in ${waitMs}ms ` + + `(attempt ${attempt + 1}/${maxAttempts}, remaining budget ${remainingBudgetMs}ms)`, ); // Abort-aware sleep: if the caller's signal fires while we're waiting, // cancel the delay immediately instead of blocking for the full duration. @@ -54,6 +96,7 @@ export async function fetchWithRateLimit(url: string, init?: RequestInit): Promi }, waitMs); signal?.addEventListener('abort', onAbort, { once: true }); }); + totalWaitMs += waitMs; } // Should never reach here, but satisfies TypeScript @@ -90,6 +133,10 @@ export async function fetchUserGuilds( }, signal, cache: 'no-store', + rateLimit: { + maxRetryDelayMs: 2_000, + totalRetryBudgetMs: 4_000, + }, }); if (!response.ok) { @@ -161,6 +208,11 @@ export async function fetchBotGuilds(signal?: AbortSignal): Promise ({ getMutualGuilds: (...args: unknown[]) => mockGetMutualGuilds(...args), })); +const mockGetBotApiBaseUrl = vi.fn(); +vi.mock("@/lib/bot-api", () => ({ + getBotApiBaseUrl: () => mockGetBotApiBaseUrl(), +})); import { GET } from "@/app/api/guilds/route"; @@ -31,13 +35,30 @@ function createMockRequest(url = "http://localhost:3000/api/guilds"): NextReques describe("GET /api/guilds", () => { const originalSecret = process.env.NEXTAUTH_SECRET; + const originalBotApiUrl = process.env.BOT_API_URL; + const originalBotApiSecret = process.env.BOT_API_SECRET; + const realFetch = globalThis.fetch; beforeEach(() => { vi.clearAllMocks(); process.env.NEXTAUTH_SECRET = "a-valid-secret-that-is-at-least-32-characters-long"; + delete process.env.BOT_API_URL; + delete process.env.BOT_API_SECRET; + mockGetBotApiBaseUrl.mockReturnValue(null); }); afterEach(() => { + globalThis.fetch = realFetch; + if (originalBotApiUrl === undefined) { + delete process.env.BOT_API_URL; + } else { + process.env.BOT_API_URL = originalBotApiUrl; + } + if (originalBotApiSecret === undefined) { + delete process.env.BOT_API_SECRET; + } else { + process.env.BOT_API_SECRET = originalBotApiSecret; + } if (originalSecret === undefined) { delete process.env.NEXTAUTH_SECRET; } else { @@ -124,4 +145,134 @@ describe("GET /api/guilds", () => { const body = await response.json(); expect(body.error).toBe("Failed to fetch guilds"); }); + + it("augments guilds with bot-evaluated access levels when bot api is configured", async () => { + process.env.BOT_API_SECRET = "bot-secret"; + mockGetBotApiBaseUrl.mockReturnValue("http://bot.internal/api/v1"); + + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => [{ id: "1", access: "moderator" }], + status: 200, + statusText: "OK", + } as Response); + + mockGetToken.mockResolvedValue({ + sub: "123", + id: "discord-user-123", + accessToken: "valid-discord-token", + }); + mockGetMutualGuilds.mockResolvedValue([ + { + id: "1", + name: "Server 1", + icon: null, + owner: false, + permissions: "0", + features: [], + botPresent: true, + }, + ]); + + const response = await GET(createMockRequest()); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body).toEqual([ + expect.objectContaining({ + id: "1", + access: "moderator", + }), + ]); + expect(globalThis.fetch).toHaveBeenCalledWith( + expect.stringContaining("/guilds/access?"), + expect.objectContaining({ + headers: { "x-api-secret": "bot-secret" }, + }), + ); + }); + + it("ignores unknown access values from the bot api", async () => { + process.env.BOT_API_SECRET = "bot-secret"; + mockGetBotApiBaseUrl.mockReturnValue("http://bot.internal/api/v1"); + + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => [{ id: "1", access: "super-admin" }], + status: 200, + statusText: "OK", + } as Response); + + mockGetToken.mockResolvedValue({ + sub: "123", + id: "discord-user-123", + accessToken: "valid-discord-token", + }); + mockGetMutualGuilds.mockResolvedValue([ + { + id: "1", + name: "Server 1", + icon: null, + owner: false, + permissions: "0", + features: [], + botPresent: true, + access: "viewer", + }, + ]); + + const response = await GET(createMockRequest()); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body).toEqual([ + expect.objectContaining({ + id: "1", + access: "viewer", + }), + ]); + }); + + it("batches guild access lookups to avoid exceeding the 100-guild API cap", async () => { + process.env.BOT_API_SECRET = "bot-secret"; + mockGetBotApiBaseUrl.mockReturnValue("http://bot.internal/api/v1"); + + globalThis.fetch = vi + .fn() + .mockResolvedValue({ + ok: true, + json: async () => [], + status: 200, + statusText: "OK", + } as Response); + + mockGetToken.mockResolvedValue({ + sub: "123", + id: "discord-user-123", + accessToken: "valid-discord-token", + }); + mockGetMutualGuilds.mockResolvedValue( + Array.from({ length: 205 }, (_, index) => ({ + id: String(index + 1), + name: `Server ${index + 1}`, + icon: null, + owner: false, + permissions: "0", + features: [], + botPresent: true, + })), + ); + + const response = await GET(createMockRequest()); + + expect(response.status).toBe(200); + expect(globalThis.fetch).toHaveBeenCalledTimes(3); + + const urls = (globalThis.fetch as ReturnType).mock.calls.map( + (call) => new URL(call[0] as string), + ); + expect(urls.map((url) => url.searchParams.get("guildIds")?.split(",").length)).toEqual([ + 100, 100, 5, + ]); + }); }); diff --git a/web/tests/api/xp-route.test.ts b/web/tests/api/xp-route.test.ts index 74ad62897..83f407ea7 100644 --- a/web/tests/api/xp-route.test.ts +++ b/web/tests/api/xp-route.test.ts @@ -4,24 +4,30 @@ import { NextRequest } from 'next/server'; // ─── Hoisted mocks ──────────────────────────────────────────────────────────── const { - mockAuthorizeGuildAdmin, + mockAuthorizeGuildModerator, mockGetBotApiConfig, mockBuildUpstreamUrl, mockProxyToBotApi, + mockGetToken, } = vi.hoisted(() => ({ - mockAuthorizeGuildAdmin: vi.fn(), + mockAuthorizeGuildModerator: vi.fn(), mockGetBotApiConfig: vi.fn(), mockBuildUpstreamUrl: vi.fn(), mockProxyToBotApi: vi.fn(), + mockGetToken: vi.fn(), })); vi.mock('@/lib/bot-api-proxy', () => ({ - authorizeGuildAdmin: (...args: unknown[]) => mockAuthorizeGuildAdmin(...args), + authorizeGuildModerator: (...args: unknown[]) => mockAuthorizeGuildModerator(...args), getBotApiConfig: (...args: unknown[]) => mockGetBotApiConfig(...args), buildUpstreamUrl: (...args: unknown[]) => mockBuildUpstreamUrl(...args), proxyToBotApi: (...args: unknown[]) => mockProxyToBotApi(...args), })); +vi.mock('next-auth/jwt', () => ({ + getToken: (...args: unknown[]) => mockGetToken(...args), +})); + vi.mock('@/lib/logger', () => ({ logger: { error: vi.fn(), warn: vi.fn(), info: vi.fn() }, })); @@ -47,9 +53,10 @@ function makeParams(guildId = 'g1', userId = 'u1') { describe('POST /api/guilds/:guildId/members/:userId/xp', () => { beforeEach(() => { vi.clearAllMocks(); - mockAuthorizeGuildAdmin.mockResolvedValue(null); // authorized + mockAuthorizeGuildModerator.mockResolvedValue(null); // authorized mockGetBotApiConfig.mockReturnValue({ baseUrl: 'http://bot:3001', secret: 's3cret' }); mockBuildUpstreamUrl.mockReturnValue(new URL('http://bot:3001/guilds/g1/members/u1/xp')); + mockGetToken.mockResolvedValue({ id: 'moderator-1' }); mockProxyToBotApi.mockResolvedValue( new Response(JSON.stringify({ ok: true }), { status: 200 }), ); @@ -169,4 +176,35 @@ describe('POST /api/guilds/:guildId/members/:userId/xp', () => { expect(forwardedBody).toEqual({ amount: 10, reason: 'ok' }); expect(forwardedBody).not.toHaveProperty('extra'); }); + + it('forwards the authenticated moderator id to the bot api', async () => { + await POST(makeRequest({ amount: 10 }), makeParams()); + + expect(mockProxyToBotApi).toHaveBeenCalled(); + expect(mockProxyToBotApi.mock.calls[0][4].headers).toMatchObject({ + 'Content-Type': 'application/json', + 'x-discord-user-id': 'moderator-1', + }); + }); + + it('falls back to token.sub when token.id is unavailable', async () => { + mockGetToken.mockResolvedValueOnce({ sub: 'nextauth-sub-only' }); + + const res = await POST(makeRequest({ amount: 10 }), makeParams()); + + expect(res.status).toBe(200); + expect(mockProxyToBotApi).toHaveBeenCalled(); + expect(mockProxyToBotApi.mock.calls[0][4].headers).toMatchObject({ + 'x-discord-user-id': 'nextauth-sub-only', + }); + }); + + it('returns 401 when neither token.id nor token.sub is available', async () => { + mockGetToken.mockResolvedValueOnce({}); + + const res = await POST(makeRequest({ amount: 10 }), makeParams()); + + expect(res.status).toBe(401); + expect(mockProxyToBotApi).not.toHaveBeenCalled(); + }); }); diff --git a/web/tests/components/dashboard/analytics-dashboard.test.tsx b/web/tests/components/dashboard/analytics-dashboard.test.tsx index a2b26ce97..ca0eb1c7c 100644 --- a/web/tests/components/dashboard/analytics-dashboard.test.tsx +++ b/web/tests/components/dashboard/analytics-dashboard.test.tsx @@ -158,32 +158,36 @@ describe("AnalyticsDashboard", () => { expect(screen.getByLabelText("Active AI conversations value")).not.toHaveTextContent(/^0$/); }); - it("omits interval query param for custom range so server can auto-detect", async () => { - localStorage.setItem(SELECTED_GUILD_KEY, "guild-1"); - - const fetchSpy = vi.spyOn(global, "fetch").mockResolvedValue({ - ok: true, - status: 200, - json: () => Promise.resolve(analyticsPayload), - } as Response); - - const user = userEvent.setup(); - render(); - - await waitFor(() => { - expect(fetchSpy).toHaveBeenCalled(); - }); - - await user.click(screen.getByRole("button", { name: "Custom" })); - - await waitFor(() => { - const customCall = fetchSpy.mock.calls - .map(([url]) => String(url)) - .find((url) => url.includes("range=custom")); - expect(customCall).toBeDefined(); - expect(customCall).not.toContain("interval="); - }); - }); + it( + "omits interval query param for custom range so server can auto-detect", + async () => { + localStorage.setItem(SELECTED_GUILD_KEY, "guild-1"); + + const fetchSpy = vi.spyOn(global, "fetch").mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve(analyticsPayload), + } as Response); + + const user = userEvent.setup(); + render(); + + await waitFor(() => { + expect(fetchSpy).toHaveBeenCalled(); + }); + + await user.click(screen.getByRole("button", { name: "Custom" })); + + await waitFor(() => { + const customCall = fetchSpy.mock.calls + .map(([url]) => String(url)) + .find((url) => url.includes("range=custom")); + expect(customCall).toBeDefined(); + expect(customCall).not.toContain("interval="); + }); + }, + 30_000, + ); it("applies accessible scope attributes to heatmap table headers", async () => { localStorage.setItem(SELECTED_GUILD_KEY, "guild-1"); diff --git a/web/tests/components/dashboard/config-diff-modal.test.tsx b/web/tests/components/dashboard/config-diff-modal.test.tsx index 878d3d08e..fd2cdbce6 100644 --- a/web/tests/components/dashboard/config-diff-modal.test.tsx +++ b/web/tests/components/dashboard/config-diff-modal.test.tsx @@ -19,10 +19,14 @@ describe('ConfigDiffModal', () => { vi.clearAllMocks(); }); - it('renders the dialog when open', () => { - render(); - expect(screen.getByText('Review Changes Before Saving')).toBeInTheDocument(); - }); + it( + 'renders the dialog when open', + () => { + render(); + expect(screen.getByText('Review Changes Before Saving')).toBeInTheDocument(); + }, + 30_000, + ); it('shows changed sections as badges', () => { render(); diff --git a/web/tests/components/dashboard/config-editor-autosave.test.tsx b/web/tests/components/dashboard/config-editor-autosave.test.tsx index 340bf231c..86f07b499 100644 --- a/web/tests/components/dashboard/config-editor-autosave.test.tsx +++ b/web/tests/components/dashboard/config-editor-autosave.test.tsx @@ -10,6 +10,13 @@ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; import { render, screen, waitFor, act, fireEvent } from '@testing-library/react'; +let mockPathname = '/dashboard/settings/ai-automation'; + +vi.mock('next/navigation', () => ({ + usePathname: () => mockPathname, + useRouter: () => ({ push: vi.fn(), replace: vi.fn(), prefetch: vi.fn() }), +})); + // ── Mocks ───────────────────────────────────────────────────────── vi.mock('sonner', () => ({ @@ -60,7 +67,20 @@ vi.mock('@/components/dashboard/config-diff', () => ({ })); vi.mock('@/components/dashboard/config-diff-modal', () => ({ - ConfigDiffModal: () =>
, + ConfigDiffModal: ({ + open, + onConfirm, + }: { + open: boolean; + onConfirm: () => void; + }) => + open ? ( +
+ +
+ ) : null, })); // ── Fixtures ────────────────────────────────────────────────────── @@ -92,6 +112,7 @@ const minimalConfig = { describe('ConfigEditor integration', () => { beforeEach(() => { + mockPathname = '/dashboard/settings/ai-automation'; localStorage.clear(); localStorage.setItem('volvox-bot-selected-guild', 'guild-123'); }); @@ -101,7 +122,9 @@ describe('ConfigEditor integration', () => { vi.unstubAllGlobals(); }); - it('loads config without issuing any PATCH request', async () => { + it( + 'loads config without issuing any PATCH request', + async () => { const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200, @@ -112,10 +135,9 @@ describe('ConfigEditor integration', () => { const { ConfigEditor } = await import('@/components/dashboard/config-editor'); render(); - // Wait for config to load await waitFor( () => { - expect(screen.getByTestId('system-prompt')).toBeInTheDocument(); + expect(screen.getAllByText('AI Chat').length).toBeGreaterThan(0); }, { timeout: 3000 }, ); @@ -125,9 +147,13 @@ describe('ConfigEditor integration', () => { (call: unknown[]) => (call[1] as { method?: string } | undefined)?.method === 'PATCH', ); expect(patchCalls).toHaveLength(0); - }); + }, + 30_000, + ); - it('renders all section components after loading', async () => { + it( + 'renders AI settings on the AI category route', + async () => { const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200, @@ -140,16 +166,45 @@ describe('ConfigEditor integration', () => { await waitFor( () => { - expect(screen.getByText('Bot Configuration')).toBeInTheDocument(); + expect(screen.getAllByText('AI Chat').length).toBeGreaterThan(0); }, { timeout: 3000 }, ); - // Check that main sections are rendered - expect(screen.getByText('AI Chat')).toBeInTheDocument(); - expect(screen.getByText('Welcome Messages')).toBeInTheDocument(); - expect(screen.getByText('Save Changes')).toBeInTheDocument(); - }); + expect(screen.getAllByText('AI Chat').length).toBeGreaterThan(0); + expect(screen.getByText('Settings')).toBeInTheDocument(); + expect(screen.getAllByText('Save Changes').length).toBeGreaterThan(0); + }, + 30_000, + ); + + it( + 'renders onboarding settings on the onboarding category route', + async () => { + mockPathname = '/dashboard/settings/onboarding-growth'; + + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve(minimalConfig), + }); + vi.stubGlobal('fetch', fetchMock); + + const { ConfigEditor } = await import('@/components/dashboard/config-editor'); + render(); + + await waitFor( + () => { + expect(screen.getAllByText('Welcome Messages').length).toBeGreaterThan(0); + }, + { timeout: 3000 }, + ); + + expect(screen.getAllByText('Welcome Messages').length).toBeGreaterThan(0); + expect(screen.getAllByText('Save Changes').length).toBeGreaterThan(0); + }, + 30_000, + ); it('renders with initial disabled discard button', async () => { const fetchMock = vi.fn().mockResolvedValue({ @@ -164,7 +219,7 @@ describe('ConfigEditor integration', () => { await waitFor( () => { - expect(screen.getByTestId('system-prompt')).toBeInTheDocument(); + expect(screen.getByTestId('discard-button')).toBeInTheDocument(); }, { timeout: 3000 }, ); @@ -189,6 +244,14 @@ describe('ConfigEditor integration', () => { ...minimalConfig.ai, systemPrompt: 'Updated prompt', }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: () => Promise.resolve({ + ...minimalConfig, + ai: { ...minimalConfig.ai, systemPrompt: 'Updated prompt' }, + }), }); vi.stubGlobal('fetch', fetchMock); @@ -207,12 +270,26 @@ describe('ConfigEditor integration', () => { saveButton.click(); }); + const confirmButton = await screen.findByRole('button', { name: 'Confirm Save' }); + await act(async () => { + confirmButton.click(); + }); + await waitFor(() => { - expect(fetchMock).toHaveBeenCalledWith('/api/guilds/guild-123/config', { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ path: 'ai.systemPrompt', value: 'Updated prompt' }), - }); + const patchCall = fetchMock.mock.calls.find( + (call: unknown[]) => + call[0] === '/api/guilds/guild-123/config' && + (call[1] as { method?: string } | undefined)?.method === 'PATCH', + ); + + expect(patchCall).toBeDefined(); + expect(patchCall?.[1]).toEqual( + expect.objectContaining({ + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path: 'ai.systemPrompt', value: 'Updated prompt' }), + }), + ); }); }); diff --git a/web/tests/components/layout/server-selector.test.tsx b/web/tests/components/layout/server-selector.test.tsx index c5cdef891..fd1e91698 100644 --- a/web/tests/components/layout/server-selector.test.tsx +++ b/web/tests/components/layout/server-selector.test.tsx @@ -22,8 +22,17 @@ vi.mock("@/lib/guild-selection", async () => { }); import { ServerSelector } from '@/components/layout/server-selector'; +import { GuildDirectoryProvider } from '@/components/layout/guild-directory-context'; import { SELECTED_GUILD_KEY } from '@/lib/guild-selection'; +function renderServerSelector() { + return render( + + + , + ); +} + describe('ServerSelector', () => { let fetchSpy: ReturnType; const originalClientId = process.env.NEXT_PUBLIC_DISCORD_CLIENT_ID; @@ -45,7 +54,7 @@ describe('ServerSelector', () => { it('shows loading state initially', () => { fetchSpy.mockReturnValue(new Promise(() => {})); // never resolves - render(); + renderServerSelector(); expect(screen.getByText('Loading workspaces...')).toBeInTheDocument(); }); @@ -55,7 +64,7 @@ describe('ServerSelector', () => { ok: true, json: () => Promise.resolve([]), } as Response); - render(); + renderServerSelector(); await waitFor(() => { expect(screen.getByText('No shared servers yet')).toBeInTheDocument(); expect( @@ -71,7 +80,7 @@ describe('ServerSelector', () => { json: () => Promise.resolve([]), } as Response); - render(); + renderServerSelector(); await waitFor(() => { expect(screen.getByRole('link', { name: /Invite Volvox\.Bot/i })).toHaveAttribute( @@ -97,7 +106,7 @@ describe('ServerSelector', () => { ok: true, json: () => Promise.resolve(guilds), } as Response); - render(); + renderServerSelector(); await waitFor(() => { expect(screen.getByText("Test Server")).toBeInTheDocument(); }); @@ -123,7 +132,7 @@ describe('ServerSelector', () => { json: () => Promise.resolve(guilds), } as Response); - render(); + renderServerSelector(); await waitFor(() => { expect(screen.getByText("Restored Server")).toBeInTheDocument(); @@ -150,7 +159,7 @@ describe('ServerSelector', () => { json: () => Promise.resolve(guilds), } as Response); - render(); + renderServerSelector(); await waitFor(() => { expect(screen.getByText("Default Server")).toBeInTheDocument(); @@ -178,7 +187,7 @@ describe('ServerSelector', () => { json: () => Promise.resolve(guilds), } as Response); - render(); + renderServerSelector(); await waitFor(() => { expect(screen.getByText("Default Server")).toBeInTheDocument(); @@ -200,7 +209,7 @@ describe('ServerSelector', () => { it('shows error state with retry button on fetch failure', async () => { fetchSpy.mockRejectedValue(new Error("Network error")); - render(); + renderServerSelector(); await waitFor(() => { expect(screen.getByText("Couldn't load workspaces")).toBeInTheDocument(); expect(screen.getByText('Retry')).toBeInTheDocument(); @@ -212,7 +221,7 @@ describe('ServerSelector', () => { ok: false, status: 500, } as Response); - render(); + renderServerSelector(); await waitFor(() => { expect(screen.getByText("Couldn't load workspaces")).toBeInTheDocument(); }); @@ -224,7 +233,7 @@ describe('ServerSelector', () => { // First call fails fetchSpy.mockRejectedValueOnce(new Error("Network error")); - render(); + renderServerSelector(); await waitFor(() => { expect(screen.getByText("Retry")).toBeInTheDocument(); }); @@ -273,7 +282,7 @@ describe('ServerSelector', () => { json: () => Promise.resolve(guilds), } as Response); - render(); + renderServerSelector(); await waitFor(() => { expect(screen.getByText("No manageable servers")).toBeInTheDocument(); @@ -281,7 +290,7 @@ describe('ServerSelector', () => { await userEvent.setup().click(screen.getByRole("button", { name: /No manageable servers/i })); - expect(screen.getByText(/You need mod or admin permissions/i)).toBeInTheDocument(); + expect(screen.getByText(/You need moderator, admin, or owner permissions/i)).toBeInTheDocument(); expect(screen.getByRole("menuitem", { name: /Viewer Server/i })).toHaveAttribute( "href", "/community/viewer-1", @@ -293,6 +302,35 @@ describe('ServerSelector', () => { expect(mockBroadcastSelectedGuild).not.toHaveBeenCalled(); }); + it('treats explicit moderator access as manageable without discord permission bits', async () => { + const guilds = [ + { + id: "mod-1", + name: "Moderator Server", + icon: null, + owner: false, + permissions: "0", + access: "moderator", + features: [], + botPresent: true, + }, + ]; + + fetchSpy.mockResolvedValue({ + ok: true, + json: () => Promise.resolve(guilds), + } as Response); + + renderServerSelector(); + + await waitFor(() => { + expect(screen.getByText("Moderator Server")).toBeInTheDocument(); + }); + + expect(screen.queryByText("No manageable servers")).not.toBeInTheDocument(); + expect(mockBroadcastSelectedGuild).toHaveBeenCalledWith("mod-1"); + }); + it('ignores invalid guild records from the api response', async () => { const guilds = [ { @@ -317,7 +355,7 @@ describe('ServerSelector', () => { json: () => Promise.resolve(guilds), } as Response); - render(); + renderServerSelector(); await waitFor(() => { expect(screen.getByText("Valid Server")).toBeInTheDocument(); diff --git a/web/tests/components/layout/sidebar.test.tsx b/web/tests/components/layout/sidebar.test.tsx index 13715a21d..41cf615b2 100644 --- a/web/tests/components/layout/sidebar.test.tsx +++ b/web/tests/components/layout/sidebar.test.tsx @@ -1,17 +1,42 @@ -import { describe, expect, it, vi } from 'vitest'; -import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; // Mock next/navigation vi.mock("next/navigation", () => ({ usePathname: () => "/dashboard", })); +vi.mock("@/hooks/use-guild-selection", () => ({ + useGuildSelection: () => "guild-1", +})); +import { GuildDirectoryProvider } from '@/components/layout/guild-directory-context'; import { Sidebar } from '@/components/layout/sidebar'; +function renderSidebar(props: { onNavClick?: () => void } = {}) { + return render( + + + , + ); +} + describe('Sidebar', () => { + let fetchSpy: ReturnType; + + beforeEach(() => { + fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValue({ + ok: true, + json: async () => [], + } as Response); + }); + + afterEach(() => { + fetchSpy.mockRestore(); + }); + it('renders navigation links', () => { - render(); + renderSidebar(); expect(screen.getByText('Overview')).toBeInTheDocument(); expect(screen.getByText('Moderation')).toBeInTheDocument(); expect(screen.getByText('AI Chat')).toBeInTheDocument(); @@ -21,7 +46,7 @@ describe('Sidebar', () => { }); it('highlights the active route and marks it as the current page', () => { - render(); + renderSidebar(); const overviewLink = screen.getByText('Overview').closest('a'); expect(overviewLink).not.toBeNull(); expect(overviewLink?.className).toContain('sidebar-item-active'); @@ -31,8 +56,39 @@ describe('Sidebar', () => { it('calls onNavClick when a link is clicked', async () => { const user = userEvent.setup(); const onNavClick = vi.fn(); - render(); + renderSidebar({ onNavClick }); await user.click(screen.getByText('Moderation')); expect(onNavClick).toHaveBeenCalled(); }); + + it('hides non-moderation pages for moderator-only guild access', async () => { + fetchSpy.mockResolvedValueOnce({ + ok: true, + json: async () => [ + { + id: 'guild-1', + name: 'Guild One', + icon: null, + owner: false, + permissions: '0', + access: 'moderator', + features: [], + botPresent: true, + }, + ], + } as Response); + + renderSidebar(); + + await waitFor(() => { + expect(screen.queryByText('Overview')).not.toBeInTheDocument(); + }); + + expect(screen.getByText('Moderation')).toBeInTheDocument(); + expect(screen.getByText('Members')).toBeInTheDocument(); + expect(screen.getByText('Tickets')).toBeInTheDocument(); + expect(screen.queryByText('Bot Config')).not.toBeInTheDocument(); + expect(screen.queryByText('AI Chat')).not.toBeInTheDocument(); + expect(screen.queryByText('Settings')).not.toBeInTheDocument(); + }); }); diff --git a/web/tests/components/ui/discord-markdown-editor.test.tsx b/web/tests/components/ui/discord-markdown-editor.test.tsx index b9452ee86..828c65962 100644 --- a/web/tests/components/ui/discord-markdown-editor.test.tsx +++ b/web/tests/components/ui/discord-markdown-editor.test.tsx @@ -222,4 +222,45 @@ describe('DiscordMarkdownEditor', () => { expect(onChange).toHaveBeenCalledWith(expected); }); + + it('passes maxLength through to the textarea', () => { + render(); + expect(screen.getByLabelText('Markdown editor')).toHaveAttribute('maxlength', '25'); + }); + + it('clamps toolbar edits to maxLength', async () => { + const onChange = vi.fn(); + const user = userEvent.setup(); + render(); + + const textarea = screen.getByPlaceholderText('Enter your message...') as HTMLTextAreaElement; + await user.click(textarea); + textarea.setSelectionRange(0, 5); + await user.click(screen.getByLabelText('Bold')); + + expect(onChange).toHaveBeenCalledWith('**hell'); + }); + + it('clamps inserted variables to maxLength', async () => { + const onChange = vi.fn(); + const user = userEvent.setup(); + render( + , + ); + + const textarea = screen.getByPlaceholderText('Enter your message...') as HTMLTextAreaElement; + await user.click(textarea); + textarea.setSelectionRange(5, 5); + + await user.click(screen.getByLabelText('Insert variable')); + await user.click(screen.getByText('username')); + + expect(onChange).toHaveBeenCalledWith('hello{{u'); + }); }); diff --git a/web/tests/components/ui/embed-builder.test.tsx b/web/tests/components/ui/embed-builder.test.tsx index fff6e0d73..0ee6becf3 100644 --- a/web/tests/components/ui/embed-builder.test.tsx +++ b/web/tests/components/ui/embed-builder.test.tsx @@ -21,6 +21,31 @@ function renderBuilder(overrides: Partial = {}, variables: string[] return { config, onChange, ...result }; } +function renderControlledBuilder( + overrides: Partial = {}, + variables: string[] = [], + onChange = vi.fn(), +) { + const initialValue = { ...defaultEmbedConfig(), ...overrides }; + + function Wrapper() { + const [value, setValue] = React.useState(initialValue); + return ( + { + onChange(next); + setValue(next); + }} + variables={variables} + /> + ); + } + + const result = render(); + return { onChange, ...result }; +} + describe('EmbedBuilder', () => { // ── Rendering ────────────────────────────────────────────────── @@ -338,6 +363,40 @@ describe('EmbedBuilder', () => { await user.type(footerInput, 'My footer'); expect(onChange).toHaveBeenCalled(); }); + + it('does not emit title updates that would exceed the total embed character cap', async () => { + const user = userEvent.setup(); + const { onChange } = renderControlledBuilder({ + ...defaultEmbedConfig(), + description: 'd'.repeat(4096), + footerText: 'f'.repeat(1900), + }); + + await user.type(screen.getByPlaceholderText('Embed title...'), 'abcdef'); + + expect(onChange).toHaveBeenCalled(); + const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1][0]; + expect(lastCall.title).toBe('abcd'); + expect(getTotalCharCount(lastCall)).toBe(CHAR_LIMITS.total); + }); + + it('trims field edits to stay within the total embed character cap', async () => { + const user = userEvent.setup(); + const { onChange } = renderControlledBuilder({ + ...defaultEmbedConfig(), + title: 't'.repeat(256), + description: 'd'.repeat(4096), + footerText: 'f'.repeat(1647), + fields: [{ id: 'field-1', name: '', value: '', inline: false }], + }); + + await user.type(screen.getByPlaceholderText('Field name'), 'abcdef'); + + expect(onChange).toHaveBeenCalled(); + const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1][0]; + expect(lastCall.fields[0].name).toBe('a'); + expect(getTotalCharCount(lastCall)).toBeLessThanOrEqual(CHAR_LIMITS.total); + }); }); // ── Preview component ───────────────────────────────────────────── diff --git a/web/tests/hooks/use-guild-role.test.ts b/web/tests/hooks/use-guild-role.test.ts index e0da8a879..d83e34b45 100644 --- a/web/tests/hooks/use-guild-role.test.ts +++ b/web/tests/hooks/use-guild-role.test.ts @@ -28,8 +28,14 @@ describe('use-guild-role', () => { expect(getGuildDashboardRole(createGuild({ permissions: '8' }))).toBe('admin'); }); - it('returns admin for manage guild permissions', () => { - expect(getGuildDashboardRole(createGuild({ permissions: '32' }))).toBe('admin'); + it('returns moderator for manage guild permissions', () => { + expect(getGuildDashboardRole(createGuild({ permissions: '32' }))).toBe('moderator'); + }); + + it('prefers explicit access from the api when provided', () => { + expect(getGuildDashboardRole(createGuild({ permissions: '0', access: 'moderator' }))).toBe( + 'moderator', + ); }); it('returns moderator for kick members permissions', () => { @@ -64,4 +70,8 @@ describe('use-guild-role', () => { expect(isGuildManageable(createGuild({ permissions: '8' }))).toBe(true); expect(isGuildManageable(createGuild({ permissions: '2' }))).toBe(true); }); + + it('prefers ownership over a downgraded access field', () => { + expect(getGuildDashboardRole(createGuild({ owner: true, access: 'viewer' }))).toBe('owner'); + }); }); diff --git a/web/tests/lib/bot-api-proxy-branches.test.ts b/web/tests/lib/bot-api-proxy-branches.test.ts index 236c43bb0..60808ac99 100644 --- a/web/tests/lib/bot-api-proxy-branches.test.ts +++ b/web/tests/lib/bot-api-proxy-branches.test.ts @@ -33,9 +33,11 @@ vi.mock('@/lib/logger', () => ({ import { authorizeGuildAdmin, + authorizeGuildModerator, buildUpstreamUrl, getBotApiConfig, hasAdministratorPermission, + hasModeratorPermission, proxyToBotApi, } from '@/lib/bot-api-proxy'; @@ -69,6 +71,14 @@ describe('bot-api-proxy branch coverage', () => { expect(hasAdministratorPermission('garbage')).toBe(false); }); + it('detects moderator permissions and invalid bitfields', () => { + expect(hasModeratorPermission('32')).toBe(true); + expect(hasModeratorPermission('2')).toBe(true); + expect(hasModeratorPermission('4')).toBe(true); + expect(hasModeratorPermission('0')).toBe(false); + expect(hasModeratorPermission('garbage')).toBe(false); + }); + it('returns 401 when the session token is missing', async () => { mockGetToken.mockResolvedValue(null); @@ -132,6 +142,36 @@ describe('bot-api-proxy branch coverage', () => { await expect(authorizeGuildAdmin(createRequest(), 'guild-2', '[test]')).resolves.toBeNull(); }); + it('allows moderator access for moderator-authorized routes', async () => { + mockGetToken.mockResolvedValue({ accessToken: 'token', id: 'user-1' }); + mockGetMutualGuilds.mockResolvedValue([{ id: 'guild-1', owner: false, permissions: '0' }]); + (globalThis.fetch as ReturnType) + .mockResolvedValueOnce({ + ok: true, + json: async () => [{ id: 'guild-1', access: 'moderator' }], + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => [{ id: 'guild-1', access: 'moderator' }], + }); + + await expect(authorizeGuildModerator(createRequest(), 'guild-1', '[test]')).resolves.toBeNull(); + await expect(authorizeGuildAdmin(createRequest(), 'guild-1', '[test]')).resolves.toMatchObject({ + status: 403, + }); + }); + + it('falls back to oauth-derived access when the bot api returns an unknown access string', async () => { + mockGetToken.mockResolvedValue({ accessToken: 'token', id: 'user-1' }); + mockGetMutualGuilds.mockResolvedValue([{ id: 'guild-1', owner: false, permissions: '8' }]); + (globalThis.fetch as ReturnType).mockResolvedValue({ + ok: true, + json: async () => [{ id: 'guild-1', access: 'super-admin' }], + }); + + await expect(authorizeGuildAdmin(createRequest(), 'guild-1', '[test]')).resolves.toBeNull(); + }); + it('returns config when the bot api base url and secret are present', () => { expect(getBotApiConfig('[test]')).toEqual({ baseUrl: 'https://bot.internal', diff --git a/web/tests/lib/discord.test.ts b/web/tests/lib/discord.test.ts index 8e94e8bf4..439e30599 100644 --- a/web/tests/lib/discord.test.ts +++ b/web/tests/lib/discord.test.ts @@ -125,6 +125,46 @@ describe("fetchWithRateLimit", () => { expect(fetchSpy).toHaveBeenCalledTimes(4); }); + it("does not retry when retry-after exceeds the allowed delay cap", async () => { + const headers = new Map([["retry-after", "728"]]); + fetchSpy.mockResolvedValue({ + status: 429, + headers: { get: (key: string) => headers.get(key) ?? null }, + } as unknown as Response); + + const response = await fetchWithRateLimit("https://example.com/api"); + expect(response.status).toBe(429); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it("does not retry when the next wait would exceed the remaining retry budget", async () => { + const headers = new Map([["retry-after", "1.5"]]); + let callCount = 0; + fetchSpy.mockImplementation(() => { + callCount++; + if (callCount <= 2) { + return Promise.resolve({ + status: 429, + headers: { get: (key: string) => headers.get(key) ?? null }, + } as unknown as Response); + } + return Promise.resolve({ ok: true, status: 200 } as Response); + }); + + const promise = fetchWithRateLimit("https://example.com/api", { + rateLimit: { + maxRetries: 3, + maxRetryDelayMs: 2_000, + totalRetryBudgetMs: 2_000, + }, + }); + + await vi.advanceTimersByTimeAsync(1_600); + const response = await promise; + expect(response.status).toBe(429); + expect(fetchSpy).toHaveBeenCalledTimes(2); + }); + it("aborts sleep when signal fires during rate-limit wait", async () => { const controller = new AbortController(); const headers = new Map([["retry-after", "30"]]); // 30 seconds @@ -142,6 +182,10 @@ describe("fetchWithRateLimit", () => { const promise = fetchWithRateLimit("https://example.com/api", { signal: controller.signal, + rateLimit: { + maxRetryDelayMs: 60_000, + totalRetryBudgetMs: 60_000, + }, }); // Advance a little, then abort (well before the 30s retry-after) @@ -220,6 +264,30 @@ describe("fetchWithRateLimit", () => { const response = await promise; expect(response.status).toBe(200); }); + + it("falls back to x-ratelimit-reset-after when retry-after is malformed", async () => { + const headers = new Map([ + ["retry-after", "nope"], + ["x-ratelimit-reset-after", "0.001"], + ]); + let callCount = 0; + fetchSpy.mockImplementation(() => { + callCount++; + if (callCount === 1) { + return Promise.resolve({ + status: 429, + headers: { get: (key: string) => headers.get(key) ?? null }, + } as unknown as Response); + } + return Promise.resolve({ ok: true, status: 200 } as Response); + }); + + const promise = fetchWithRateLimit("https://example.com/api"); + await vi.advanceTimersByTimeAsync(100); + const response = await promise; + expect(response.status).toBe(200); + expect(fetchSpy).toHaveBeenCalledTimes(2); + }); }); describe("fetchUserGuilds", () => { @@ -431,6 +499,28 @@ describe("fetchBotGuilds", () => { }), ); }); + + it("fails fast when bot API retry-after is too large", async () => { + vi.useFakeTimers(); + try { + process.env.BOT_API_URL = "http://localhost:3001"; + process.env.BOT_API_SECRET = "test-secret"; + + const headers = new Map([["retry-after", "1"]]); + fetchSpy.mockResolvedValue({ + status: 429, + ok: false, + statusText: "Too Many Requests", + headers: { get: (key: string) => headers.get(key) ?? null }, + } as unknown as Response); + + const result = await fetchBotGuilds(); + expect(result).toEqual({ available: false, guilds: [] }); + expect(fetchSpy).toHaveBeenCalledTimes(1); + } finally { + vi.useRealTimers(); + } + }); }); describe("getMutualGuilds", () => {