diff --git a/.env.example b/.env.example index df657f50..b6e1552e 100644 --- a/.env.example +++ b/.env.example @@ -82,6 +82,11 @@ NEXT_PUBLIC_DISCORD_CLIENT_ID=your_discord_client_id # then right-click your name → Copy User ID. BOT_OWNER_IDS=your_discord_user_id +# ── Session Storage ────────────────────────── + +# Redis URL for session storage (optional — falls back to in-memory) +# REDIS_URL=redis://localhost:6379 + # ── Optional Integrations ──────────────────── # mem0 API key for user long-term memory (optional — memory features disabled without it) diff --git a/package.json b/package.json index d8f35d1b..931a5731 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "discord.js": "^14.25.1", "dotenv": "^17.3.1", "express": "^5.2.1", + "ioredis": "^5.9.3", "jsonwebtoken": "^9.0.3", "mem0ai": "^2.2.2", "node-pg-migrate": "^8.0.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3753ade7..f2636d96 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,9 @@ importers: express: specifier: ^5.2.1 version: 5.2.1 + ioredis: + specifier: ^5.9.3 + version: 5.9.3 jsonwebtoken: specifier: ^9.0.3 version: 9.0.3 @@ -801,6 +804,9 @@ packages: cpu: [x64] os: [win32] + '@ioredis/commands@1.5.0': + resolution: {integrity: sha512-eUgLqrMf8nJkZxT24JvVRrQya1vZkQh8BBeYNwGDqa5I0VUi8ACx7uFvAaLxintokpTenkK6DASvo/bvNbBGow==} + '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} @@ -2516,6 +2522,10 @@ packages: delegates@1.0.0: resolution: {integrity: sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==} + denque@2.1.0: + resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} + engines: {node: '>=0.10'} + depd@2.0.0: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} @@ -2968,6 +2978,10 @@ packages: resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} engines: {node: '>=12'} + ioredis@5.9.3: + resolution: {integrity: sha512-VI5tMCdeoxZWU5vjHWsiE/Su76JGhBvWF1MJnV9ZtGltHk9BmD48oDq8Tj8haZ85aceXZMxLNDQZRVo5QKNgXA==} + engines: {node: '>=12.22.0'} + ip-address@10.1.0: resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==} engines: {node: '>= 12'} @@ -3202,9 +3216,15 @@ packages: resolution: {integrity: sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==} engines: {node: '>= 12.0.0'} + lodash.defaults@4.2.0: + resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} + lodash.includes@4.3.0: resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} + lodash.isarguments@3.1.0: + resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==} + lodash.isboolean@3.0.3: resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} @@ -3867,6 +3887,14 @@ packages: resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} engines: {node: '>=8'} + redis-errors@1.2.0: + resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==} + engines: {node: '>=4'} + + redis-parser@3.0.0: + resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} + engines: {node: '>=4'} + redis@4.7.1: resolution: {integrity: sha512-S1bJDnqLftzHXHP8JsT5II/CtHWQrASX5K96REjWjlmWKrviSOLWmM7QnRLstAWsu1VBBV1ffV6DzCvxNP0UJQ==} @@ -4062,6 +4090,9 @@ packages: stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + standard-as-callback@2.1.0: + resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} + statuses@2.0.2: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} @@ -5099,6 +5130,8 @@ snapshots: '@img/sharp-win32-x64@0.34.5': optional: true + '@ioredis/commands@1.5.0': {} + '@isaacs/cliui@8.0.2': dependencies: string-width: 5.1.2 @@ -6837,6 +6870,8 @@ snapshots: delegates@1.0.0: optional: true + denque@2.1.0: {} + depd@2.0.0: {} dequal@2.0.3: {} @@ -7371,6 +7406,20 @@ snapshots: internmap@2.0.3: {} + ioredis@5.9.3: + dependencies: + '@ioredis/commands': 1.5.0 + cluster-key-slot: 1.1.2 + debug: 4.4.3 + denque: 2.1.0 + lodash.defaults: 4.2.0 + lodash.isarguments: 3.1.0 + redis-errors: 1.2.0 + redis-parser: 3.0.0 + standard-as-callback: 2.1.0 + transitivePeerDependencies: + - supports-color + ip-address@10.1.0: optional: true @@ -7600,8 +7649,12 @@ snapshots: lightningcss-win32-arm64-msvc: 1.30.2 lightningcss-win32-x64-msvc: 1.30.2 + lodash.defaults@4.2.0: {} + lodash.includes@4.3.0: {} + lodash.isarguments@3.1.0: {} + lodash.isboolean@3.0.3: {} lodash.isinteger@4.0.4: {} @@ -8293,6 +8346,12 @@ snapshots: indent-string: 4.0.0 strip-indent: 3.0.0 + redis-errors@1.2.0: {} + + redis-parser@3.0.0: + dependencies: + redis-errors: 1.2.0 + redis@4.7.1: dependencies: '@redis/bloom': 1.2.0(@redis/client@1.6.1) @@ -8567,6 +8626,8 @@ snapshots: stackback@0.0.2: {} + standard-as-callback@2.1.0: {} + statuses@2.0.2: {} std-env@3.10.0: {} diff --git a/src/api/middleware/auth.js b/src/api/middleware/auth.js index 8e6ab545..4fe40912 100644 --- a/src/api/middleware/auth.js +++ b/src/api/middleware/auth.js @@ -34,7 +34,7 @@ export function isValidSecret(secret) { * @returns {import('express').RequestHandler} Express middleware function */ export function requireAuth() { - return (req, res, next) => { + return async (req, res, next) => { // Try API secret first const apiSecret = req.headers['x-api-secret']; if (apiSecret) { @@ -59,7 +59,7 @@ export function requireAuth() { } // Try OAuth2 JWT - if (handleOAuthJwt(req, res, next)) { + if (await handleOAuthJwt(req, res, next)) { return; } diff --git a/src/api/middleware/oauth.js b/src/api/middleware/oauth.js index 19c2557c..f3c72b63 100644 --- a/src/api/middleware/oauth.js +++ b/src/api/middleware/oauth.js @@ -12,7 +12,7 @@ import { handleOAuthJwt } from './oauthJwt.js'; * @returns {import('express').RequestHandler} Express middleware function */ export function requireOAuth() { - return (req, res, next) => { - return handleOAuthJwt(req, res, next, { missingTokenError: 'No token provided' }); + return async (req, res, next) => { + await handleOAuthJwt(req, res, next, { missingTokenError: 'No token provided' }); }; } diff --git a/src/api/middleware/oauthJwt.js b/src/api/middleware/oauthJwt.js index 52fabb6b..0446e4cd 100644 --- a/src/api/middleware/oauthJwt.js +++ b/src/api/middleware/oauthJwt.js @@ -25,9 +25,10 @@ export function getBearerToken(authHeader) { * @param {import('express').Response} res - Express response * @param {import('express').NextFunction} next - Express next callback * @param {{ missingTokenError?: string }} [options] - Behavior options - * @returns {boolean} True if middleware chain has been handled, false if no Bearer token was provided and no missing-token error was requested + * @returns {Promise} True if middleware chain has been handled, false if no Bearer token + * was provided and no missing-token error was requested */ -export function handleOAuthJwt(req, res, next, options = {}) { +export async function handleOAuthJwt(req, res, next, options = {}) { const token = getBearerToken(req.headers.authorization); if (!token) { if (options.missingTokenError) { @@ -37,7 +38,7 @@ export function handleOAuthJwt(req, res, next, options = {}) { return false; } - const result = verifyJwtToken(token); + const result = await verifyJwtToken(token); if (result.error) { if (result.status === 500) { error('SESSION_SECRET not configured — cannot verify OAuth token', { diff --git a/src/api/middleware/verifyJwt.js b/src/api/middleware/verifyJwt.js index 117dd1ba..7c9cdd89 100644 --- a/src/api/middleware/verifyJwt.js +++ b/src/api/middleware/verifyJwt.js @@ -4,6 +4,7 @@ */ import jwt from 'jsonwebtoken'; +import { error as logError } from '../../logger.js'; import { getSessionToken } from '../utils/sessionStore.js'; /** @@ -34,11 +35,11 @@ function getSecret() { * Verify a JWT token and validate the associated server-side session. * * @param {string} token - The JWT Bearer token to verify - * @returns {{ user: Object } | { error: string, status: number }} + * @returns {Promise<{ user: Object } | { error: string, status: number }>} * On success: `{ user }` with the decoded JWT payload. * On failure: `{ error, status }` with an error message and HTTP status code. */ -export function verifyJwtToken(token) { +export async function verifyJwtToken(token) { let secret; try { secret = getSecret(); @@ -46,13 +47,23 @@ export function verifyJwtToken(token) { return { error: 'Session not configured', status: 500 }; } + let decoded; try { - const decoded = jwt.verify(token, secret, { algorithms: ['HS256'] }); - if (!getSessionToken(decoded.userId)) { - return { error: 'Session expired or revoked', status: 401 }; - } - return { user: decoded }; + decoded = jwt.verify(token, secret, { algorithms: ['HS256'] }); } catch { return { error: 'Invalid or expired token', status: 401 }; } + + let sessionToken; + try { + sessionToken = await getSessionToken(decoded.userId); + } catch (err) { + logError('Session lookup failed', { error: err.message, userId: decoded.userId }); + return { error: 'Session lookup failed', status: 503 }; + } + + if (!sessionToken) { + return { error: 'Session expired or revoked', status: 401 }; + } + return { user: decoded }; } diff --git a/src/api/routes/auth.js b/src/api/routes/auth.js index a9f82245..3ec3da30 100644 --- a/src/api/routes/auth.js +++ b/src/api/routes/auth.js @@ -229,7 +229,7 @@ router.get('/discord/callback', async (req, res) => { } // Store access token server-side (never in the JWT) - sessionStore.set(user.id, accessToken); + await sessionStore.set(user.id, accessToken); // Create JWT with user info only (no access token — stored server-side) const token = jwt.sign( @@ -269,7 +269,14 @@ router.get('/discord/callback', async (req, res) => { */ router.get('/me', requireOAuth(), async (req, res) => { const { userId, username, avatar } = req.user; - const accessToken = sessionStore.get(userId); + + let accessToken; + try { + accessToken = await sessionStore.get(userId); + } catch (err) { + error('Redis error fetching session in /me', { error: err.message, userId }); + return res.status(503).json({ error: 'Session store unavailable' }); + } let guilds = []; if (accessToken) { @@ -291,8 +298,16 @@ router.get('/me', requireOAuth(), async (req, res) => { /** * POST /logout — Invalidate the user's server-side session */ -router.post('/logout', requireOAuth(), (req, res) => { - sessionStore.delete(req.user.userId); +router.post('/logout', requireOAuth(), async (req, res) => { + try { + await sessionStore.delete(req.user.userId); + } catch (err) { + error('Redis error deleting session on logout', { + error: err.message, + userId: req.user.userId, + }); + // User's intent is to log out — succeed anyway + } res.json({ message: 'Logged out successfully' }); }); diff --git a/src/api/routes/guilds.js b/src/api/routes/guilds.js index d59d9897..a34a362c 100644 --- a/src/api/routes/guilds.js +++ b/src/api/routes/guilds.js @@ -172,14 +172,23 @@ function formatBucketLabel(bucket, interval) { * @returns {boolean} `true` if the user has any of the specified permission flags on the guild, `false` otherwise. */ async function hasOAuthGuildPermission(user, guildId, anyOfFlags) { - const accessToken = getSessionToken(user?.userId); - if (!accessToken) return false; - const guilds = await fetchUserGuilds(user.userId, accessToken); - const guild = guilds.find((g) => g.id === guildId); - if (!guild) return false; - const permissions = Number(guild.permissions); - if (Number.isNaN(permissions)) return false; - return (permissions & anyOfFlags) !== 0; + try { + const accessToken = await getSessionToken(user?.userId); + if (!accessToken) return false; + const guilds = await fetchUserGuilds(user.userId, accessToken); + const guild = guilds.find((g) => g.id === guildId); + if (!guild) return false; + const permissions = Number(guild.permissions); + if (Number.isNaN(permissions)) return false; + return (permissions & anyOfFlags) !== 0; + } catch (err) { + error('Error in hasOAuthGuildPermission (session lookup or guild fetch)', { + error: err.message, + userId: user?.userId, + guildId, + }); + throw err; + } } /** @@ -307,7 +316,16 @@ router.get('/', async (req, res) => { return res.json(ownerGuilds); } - const accessToken = getSessionToken(req.user?.userId); + let accessToken; + try { + accessToken = await getSessionToken(req.user?.userId); + } catch (err) { + error('Redis error fetching session token in GET /guilds', { + error: err.message, + userId: req.user?.userId, + }); + return res.status(503).json({ error: 'Session store unavailable' }); + } if (!accessToken) { return res.status(401).json({ error: 'Missing access token' }); } diff --git a/src/api/routes/health.js b/src/api/routes/health.js index b83128fd..1b00391b 100644 --- a/src/api/routes/health.js +++ b/src/api/routes/health.js @@ -105,8 +105,9 @@ router.get('/', async (req, res) => { const pool = getRestartPool(); if (pool) { const rows = await getRestarts(pool, 20); - body.restarts = rows.map(r => ({ - timestamp: r.timestamp instanceof Date ? r.timestamp.toISOString() : String(r.timestamp), + body.restarts = rows.map((r) => ({ + timestamp: + r.timestamp instanceof Date ? r.timestamp.toISOString() : String(r.timestamp), reason: r.reason || 'unknown', version: r.version ?? null, uptimeBefore: r.uptime_seconds ?? null, diff --git a/src/api/utils/redisClient.js b/src/api/utils/redisClient.js new file mode 100644 index 00000000..b6f90369 --- /dev/null +++ b/src/api/utils/redisClient.js @@ -0,0 +1,71 @@ +/** + * Redis Client + * Lazily-initialised ioredis client for session storage. + * If REDIS_URL is not configured, getRedisClient() returns null and all + * callers fall back to the in-memory implementation. + */ + +import Redis from 'ioredis'; +import { error as logError, warn } from '../../logger.js'; + +/** @type {Redis | null} */ +let _client = null; +let _initialized = false; + +/** + * Return the ioredis client, initialising it on first call. + * Returns null if REDIS_URL is not configured. + * + * @returns {Redis | null} + */ +export function getRedisClient() { + if (_initialized) return _client; + _initialized = true; + + const redisUrl = process.env.REDIS_URL; + if (!redisUrl) return null; + + try { + _client = new Redis(redisUrl, { + maxRetriesPerRequest: 3, + enableReadyCheck: true, + lazyConnect: false, + }); + + _client.on('error', (err) => { + logError('Redis connection error', { error: err.message }); + }); + } catch (err) { + logError('Failed to initialise Redis client', { error: err.message }); + _client = null; + } + + return _client; +} + +/** + * Gracefully close the Redis connection. + * Safe to call even if Redis was never configured. + * + * @returns {Promise} + */ +export async function closeRedis() { + if (!_client) return; + try { + await _client.quit(); + } catch (err) { + warn('Redis quit error during shutdown', { error: err.message }); + } finally { + _client = null; + _initialized = false; + } +} + +/** + * Reset internal state — for testing only. + * @internal + */ +export function _resetRedisClient() { + _client = null; + _initialized = false; +} diff --git a/src/api/utils/sessionStore.js b/src/api/utils/sessionStore.js index 4e7b28d4..9e24b09b 100644 --- a/src/api/utils/sessionStore.js +++ b/src/api/utils/sessionStore.js @@ -1,46 +1,67 @@ /** * Session Store Utilities - * Shared OAuth session token storage and helpers + * Shared OAuth session token storage and helpers. + * + * Backends: + * - Redis — when REDIS_URL is set (recommended for multi-instance deployments) + * - Memory — in-process Map fallback (single-node only, lost on restart) */ +import { getRedisClient } from './redisClient.js'; + /** * Session TTL — must match the JWT `expiresIn` value in auth.js (currently "1h"). * If you change one, update the other to keep session and token lifetimes aligned. */ const SESSION_TTL_MS = 60 * 60 * 1000; // 1 hour +const SESSION_TTL_SECONDS = 60 * 60; // 1 hour, for Redis SETEX + +const SESSION_KEY_PREFIX = 'session:'; /** - * TTL-based in-memory session store: userId → { accessToken, expiresAt }. - * Extends Map to transparently handle expiry on get/has. - * - * ### Scaling Limitations + * Hybrid session store. * - * This store is **in-memory and single-process only**: - * - Sessions are lost on server restart. - * - Cannot be shared across multiple Node.js processes or containers. - * - Memory grows linearly with active sessions (mitigated by TTL + cleanup). - * - * ### Future Migration - * - * For multi-instance deployments, replace with a shared store (e.g., Redis): - * 1. Swap this class for a Redis-backed adapter with the same get/set/has/delete interface. - * 2. Use Redis TTL (`SETEX`) instead of manual expiry tracking. - * 3. Update `cleanup()` to rely on Redis key expiration. + * When REDIS_URL is configured, all mutating/reading operations hit Redis and + * return Promises. When Redis is not configured the store falls back to the + * original in-memory TTL-based Map implementation, which returns values + * synchronously (though callers should `await` regardless so they are + * compatible with both backends). * * ### Map Override Coverage * - * Only `get`, `set`, `has`, and `delete` are overridden to handle TTL. - * Inherited methods like `size`, `forEach`, `entries`, `keys`, `values` - * operate on the raw Map entries (including expired ones between cleanup cycles). - * The periodic `cleanup()` call purges expired entries to keep these reasonable. - * For most use cases (auth lookups by userId), the overridden methods suffice. + * Only `get`, `set`, `has`, `delete`, and `cleanup` are overridden to handle + * the dual-backend logic. The `clear()` method is inherited from Map and only + * affects the in-memory store — it is used in tests (which run without + * REDIS_URL and therefore never touch Redis). */ class SessionStore extends Map { + /** + * Store an access token for a user. + * + * @param {string} userId + * @param {string} accessToken + * @returns {this | Promise<'OK'>} + */ set(userId, accessToken) { + const client = getRedisClient(); + if (client) { + return client.setex(`${SESSION_KEY_PREFIX}${userId}`, SESSION_TTL_SECONDS, accessToken); + } return super.set(userId, { accessToken, expiresAt: Date.now() + SESSION_TTL_MS }); } + /** + * Get the stored access token for a user. + * Returns undefined/null if not found or expired. + * + * @param {string} userId + * @returns {string | undefined | Promise} + */ get(userId) { + const client = getRedisClient(); + if (client) { + return client.get(`${SESSION_KEY_PREFIX}${userId}`); + } const entry = super.get(userId); if (!entry) return undefined; if (Date.now() >= entry.expiresAt) { @@ -50,11 +71,40 @@ class SessionStore extends Map { return entry.accessToken; } + /** + * Check whether a valid session exists for a user. + * + * @param {string} userId + * @returns {boolean | Promise} + */ has(userId) { + const client = getRedisClient(); + if (client) { + return client.exists(`${SESSION_KEY_PREFIX}${userId}`).then((n) => n > 0); + } return this.get(userId) !== undefined; } + /** + * Remove the session for a user. + * + * @param {string} userId + * @returns {boolean | Promise} + */ + delete(userId) { + const client = getRedisClient(); + if (client) { + return client.del(`${SESSION_KEY_PREFIX}${userId}`); + } + return super.delete(userId); + } + + /** + * Purge expired in-memory entries. + * No-op when Redis is active (TTL handles expiry automatically). + */ cleanup() { + if (getRedisClient()) return; const now = Date.now(); for (const [key, entry] of super.entries()) { if (now >= entry.expiresAt) super.delete(key); @@ -66,10 +116,12 @@ export const sessionStore = new SessionStore(); /** * Get the access token for a user from the session store. - * Returns undefined if the session has expired or does not exist. + * Returns undefined/null if the session has expired or does not exist. + * + * Always `await` the return value — it is a Promise when Redis is configured. * * @param {string} userId - Discord user ID - * @returns {string|undefined} The access token, or undefined + * @returns {Promise | string | undefined} */ export function getSessionToken(userId) { return sessionStore.get(userId); diff --git a/src/api/ws/logStream.js b/src/api/ws/logStream.js index 966c5b14..0ed0d272 100644 --- a/src/api/ws/logStream.js +++ b/src/api/ws/logStream.js @@ -182,9 +182,7 @@ function validateTicket(ticket, secret) { if (!Number.isFinite(expiryNum) || expiryNum <= Date.now()) return false; // Re-derive HMAC and compare with timing-safe equality - const expected = createHmac('sha256', secret) - .update(`${nonce}.${expiry}`) - .digest('hex'); + const expected = createHmac('sha256', secret).update(`${nonce}.${expiry}`).digest('hex'); try { return timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(hmac, 'hex')); diff --git a/src/db.js b/src/db.js index dccbd9ac..8b965afe 100644 --- a/src/db.js +++ b/src/db.js @@ -3,10 +3,10 @@ * PostgreSQL connection pool and migration runner */ -import { fileURLToPath } from 'node:url'; import path from 'node:path'; -import pg from 'pg'; +import { fileURLToPath } from 'node:url'; import { runner } from 'node-pg-migrate'; +import pg from 'pg'; import { info, error as logError } from './logger.js'; const { Pool } = pg; diff --git a/src/index.js b/src/index.js index 7222f413..976529cb 100644 --- a/src/index.js +++ b/src/index.js @@ -20,13 +20,22 @@ import { fileURLToPath } from 'node:url'; import { Client, Collection, Events, GatewayIntentBits } from 'discord.js'; import { config as dotenvConfig } from 'dotenv'; import { startServer, stopServer } from './api/server.js'; +import { closeRedis } from './api/utils/redisClient.js'; import { registerConfigListeners, removeLoggingTransport, setInitialTransport, } from './config-listeners.js'; import { closeDb, getPool, initDb } from './db.js'; -import { addPostgresTransport, addWebSocketTransport, removeWebSocketTransport, debug, error, info, warn } from './logger.js'; +import { + addPostgresTransport, + addWebSocketTransport, + debug, + error, + info, + removeWebSocketTransport, + warn, +} from './logger.js'; import { getConversationHistory, initConversationHistory, @@ -221,7 +230,12 @@ client.on('interactionCreate', async (interaction) => { await command.execute(interaction); info('Command executed', { command: commandName, user: interaction.user.tag }); } catch (err) { - error('Command error', { command: commandName, error: err.message, stack: err.stack, source: 'slash_command' }); + error('Command error', { + command: commandName, + error: err.message, + stack: err.stack, + source: 'slash_command', + }); const errorMessage = { content: '❌ An error occurred while executing this command.', @@ -286,6 +300,13 @@ async function gracefulShutdown(signal) { error('Failed to close database pool', { error: err.message }); } + // 4.5. Close Redis connection (no-op if Redis was never configured) + try { + await closeRedis(); + } catch (err) { + error('Failed to close Redis connection', { error: err.message }); + } + // 5. Flush Sentry events before exit (no-op if Sentry disabled) await import('./sentry.js').then(({ Sentry }) => Sentry.flush(2000)).catch(() => {}); @@ -435,13 +456,17 @@ async function startup() { await client.login(token); // Set Sentry context now that we know the bot identity (no-op if disabled) - import('./sentry.js').then(({ Sentry, sentryEnabled }) => { - if (sentryEnabled) { - Sentry.setTag('bot.username', client.user?.tag || 'unknown'); - Sentry.setTag('bot.version', BOT_VERSION); - info('Sentry error monitoring enabled', { environment: process.env.SENTRY_ENVIRONMENT || process.env.NODE_ENV || 'production' }); - } - }).catch(() => {}); + import('./sentry.js') + .then(({ Sentry, sentryEnabled }) => { + if (sentryEnabled) { + Sentry.setTag('bot.username', client.user?.tag || 'unknown'); + Sentry.setTag('bot.version', BOT_VERSION); + info('Sentry error monitoring enabled', { + environment: process.env.SENTRY_ENVIRONMENT || process.env.NODE_ENV || 'production', + }); + } + }) + .catch(() => {}); // Start REST API server with WebSocket log streaming (non-fatal — bot continues without it) { diff --git a/src/logger.js b/src/logger.js index dd3407a7..30cca2ee 100644 --- a/src/logger.js +++ b/src/logger.js @@ -13,8 +13,8 @@ import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import winston from 'winston'; import DailyRotateFile from 'winston-daily-rotate-file'; -import { PostgresTransport } from './transports/postgres.js'; import { sentryEnabled } from './sentry.js'; +import { PostgresTransport } from './transports/postgres.js'; import { SentryTransport } from './transports/sentry.js'; import { WebSocketTransport } from './transports/websocket.js'; diff --git a/src/modules/triage.js b/src/modules/triage.js index 26b1d345..46992dee 100644 --- a/src/modules/triage.js +++ b/src/modules/triage.js @@ -189,7 +189,10 @@ async function runResponder( try { await safeSend(ch, '\uD83D\uDD0D Searching the web for that \u2014 one moment...'); } catch (notifyErr) { - warn('Failed to send WebSearch notification', { channelId, error: notifyErr?.message }); + warn('Failed to send WebSearch notification', { + channelId, + error: notifyErr?.message, + }); } } } diff --git a/src/transports/sentry.js b/src/transports/sentry.js index 764cc18c..a4b0669c 100644 --- a/src/transports/sentry.js +++ b/src/transports/sentry.js @@ -44,7 +44,10 @@ export class SentryTransport extends Transport { const tags = {}; const extra = {}; for (const [key, value] of Object.entries(meta)) { - if (SentryTransport.TAG_KEYS.has(key) && (typeof value === 'string' || typeof value === 'number')) { + if ( + SentryTransport.TAG_KEYS.has(key) && + (typeof value === 'string' || typeof value === 'number') + ) { tags[key] = String(value); } else if (key !== 'originalLevel' && key !== 'splat') { extra[key] = value; diff --git a/src/transports/websocket.js b/src/transports/websocket.js index 2537f551..cec63cbc 100644 --- a/src/transports/websocket.js +++ b/src/transports/websocket.js @@ -5,8 +5,8 @@ * WebSocket clients in real-time. Zero overhead when no clients are connected. */ -import WebSocket from 'ws'; import Transport from 'winston-transport'; +import WebSocket from 'ws'; /** * Log level severity ordering (lower = more severe). diff --git a/tests/api/middleware/auth.test.js b/tests/api/middleware/auth.test.js index 9a25d60e..3debac18 100644 --- a/tests/api/middleware/auth.test.js +++ b/tests/api/middleware/auth.test.js @@ -59,7 +59,7 @@ describe('auth middleware', () => { vi.unstubAllEnvs(); }); - it('should fall back to JWT auth when BOT_API_SECRET is not configured', () => { + it('should fall back to JWT auth when BOT_API_SECRET is not configured', async () => { vi.stubEnv('BOT_API_SECRET', ''); vi.stubEnv('SESSION_SECRET', 'jwt-test-secret'); req.headers['x-api-secret'] = 'some-secret'; @@ -70,73 +70,73 @@ describe('auth middleware', () => { req.headers.authorization = `Bearer ${token}`; const middleware = requireAuth(); - middleware(req, res, next); + await middleware(req, res, next); expect(next).toHaveBeenCalled(); expect(req.authMethod).toBe('oauth'); expect(req.user.userId).toBe('999'); }); - it('should return 401 when BOT_API_SECRET is not configured and no Bearer token is provided', () => { + it('should return 401 when BOT_API_SECRET is not configured and no Bearer token is provided', async () => { vi.stubEnv('BOT_API_SECRET', ''); req.headers['x-api-secret'] = 'some-secret'; const middleware = requireAuth(); - middleware(req, res, next); + await middleware(req, res, next); expect(res.status).toHaveBeenCalledWith(401); expect(res.json).toHaveBeenCalledWith({ error: 'Unauthorized' }); expect(next).not.toHaveBeenCalled(); }); - it('should return 401 when x-api-secret header is missing', () => { + it('should return 401 when x-api-secret header is missing', async () => { vi.stubEnv('BOT_API_SECRET', 'test-secret'); const middleware = requireAuth(); - middleware(req, res, next); + await middleware(req, res, next); expect(res.status).toHaveBeenCalledWith(401); expect(res.json).toHaveBeenCalledWith({ error: 'Unauthorized' }); expect(next).not.toHaveBeenCalled(); }); - it('should return Unauthorized when Authorization header is not Bearer and no API secret succeeds', () => { + it('should return Unauthorized when Authorization header is not Bearer and no API secret succeeds', async () => { vi.stubEnv('BOT_API_SECRET', ''); req.headers.authorization = 'Basic abc123'; const middleware = requireAuth(); - middleware(req, res, next); + await middleware(req, res, next); expect(res.status).toHaveBeenCalledWith(401); expect(res.json).toHaveBeenCalledWith({ error: 'Unauthorized' }); expect(next).not.toHaveBeenCalled(); }); - it('should return 401 with specific error when x-api-secret does not match', () => { + it('should return 401 with specific error when x-api-secret does not match', async () => { vi.stubEnv('BOT_API_SECRET', 'test-secret'); req.headers['x-api-secret'] = 'wrong-secret'; const middleware = requireAuth(); - middleware(req, res, next); + await middleware(req, res, next); expect(res.status).toHaveBeenCalledWith(401); expect(res.json).toHaveBeenCalledWith({ error: 'Invalid API secret' }); expect(next).not.toHaveBeenCalled(); }); - it('should call next() when x-api-secret header matches', () => { + it('should call next() when x-api-secret header matches', async () => { vi.stubEnv('BOT_API_SECRET', 'test-secret'); req.headers['x-api-secret'] = 'test-secret'; const middleware = requireAuth(); - middleware(req, res, next); + await middleware(req, res, next); expect(next).toHaveBeenCalled(); expect(req.authMethod).toBe('api-secret'); expect(res.status).not.toHaveBeenCalled(); }); - it('should authenticate with valid JWT Bearer token', () => { + it('should authenticate with valid JWT Bearer token', async () => { vi.stubEnv('SESSION_SECRET', 'jwt-test-secret'); sessionStore.set('123', 'discord-access-token'); const token = jwt.sign({ userId: '123', username: 'testuser' }, 'jwt-test-secret', { @@ -145,36 +145,36 @@ describe('auth middleware', () => { req.headers.authorization = `Bearer ${token}`; const middleware = requireAuth(); - middleware(req, res, next); + await middleware(req, res, next); expect(next).toHaveBeenCalled(); expect(req.authMethod).toBe('oauth'); expect(req.user.userId).toBe('123'); }); - it('should return 401 for invalid JWT Bearer token', () => { + it('should return 401 for invalid JWT Bearer token', async () => { vi.stubEnv('SESSION_SECRET', 'jwt-test-secret'); req.headers.authorization = 'Bearer invalid-token'; const middleware = requireAuth(); - middleware(req, res, next); + await middleware(req, res, next); expect(res.status).toHaveBeenCalledWith(401); expect(res.json).toHaveBeenCalledWith({ error: 'Invalid or expired token' }); }); - it('should return 500 when SESSION_SECRET is not set for JWT auth', () => { + it('should return 500 when SESSION_SECRET is not set for JWT auth', async () => { vi.stubEnv('SESSION_SECRET', ''); req.headers.authorization = 'Bearer some-token'; const middleware = requireAuth(); - middleware(req, res, next); + await middleware(req, res, next); expect(res.status).toHaveBeenCalledWith(500); expect(res.json).toHaveBeenCalledWith({ error: 'Session not configured' }); }); - it('should reject when x-api-secret is invalid even if valid JWT is present', () => { + it('should reject when x-api-secret is invalid even if valid JWT is present', async () => { vi.stubEnv('BOT_API_SECRET', 'test-secret'); vi.stubEnv('SESSION_SECRET', 'jwt-test-secret'); req.headers['x-api-secret'] = 'wrong-secret'; @@ -183,7 +183,7 @@ describe('auth middleware', () => { req.headers.authorization = `Bearer ${token}`; const middleware = requireAuth(); - middleware(req, res, next); + await middleware(req, res, next); // Wrong API secret should reject immediately — no JWT fallback expect(res.status).toHaveBeenCalledWith(401); diff --git a/tests/api/middleware/oauth.test.js b/tests/api/middleware/oauth.test.js index dab343cf..39c94f98 100644 --- a/tests/api/middleware/oauth.test.js +++ b/tests/api/middleware/oauth.test.js @@ -32,60 +32,60 @@ describe('requireOAuth middleware', () => { vi.unstubAllEnvs(); }); - it('should return 401 when no Authorization header', () => { + it('should return 401 when no Authorization header', async () => { const middleware = requireOAuth(); - middleware(req, res, next); + await middleware(req, res, next); expect(res.status).toHaveBeenCalledWith(401); expect(res.json).toHaveBeenCalledWith({ error: 'No token provided' }); expect(next).not.toHaveBeenCalled(); }); - it('should return 401 when Authorization header does not start with Bearer', () => { + it('should return 401 when Authorization header does not start with Bearer', async () => { req.headers.authorization = 'Basic abc123'; const middleware = requireOAuth(); - middleware(req, res, next); + await middleware(req, res, next); expect(res.status).toHaveBeenCalledWith(401); expect(res.json).toHaveBeenCalledWith({ error: 'No token provided' }); }); - it('should still return No token provided even if x-api-secret header is present', () => { + it('should still return No token provided even if x-api-secret header is present', async () => { req.headers['x-api-secret'] = 'test-secret'; const middleware = requireOAuth(); - middleware(req, res, next); + await middleware(req, res, next); expect(res.status).toHaveBeenCalledWith(401); expect(res.json).toHaveBeenCalledWith({ error: 'No token provided' }); expect(next).not.toHaveBeenCalled(); }); - it('should return 500 when SESSION_SECRET is not set', () => { + it('should return 500 when SESSION_SECRET is not set', async () => { vi.stubEnv('SESSION_SECRET', ''); req.headers.authorization = 'Bearer some-token'; const middleware = requireOAuth(); - middleware(req, res, next); + await middleware(req, res, next); expect(res.status).toHaveBeenCalledWith(500); expect(res.json).toHaveBeenCalledWith({ error: 'Session not configured' }); }); - it('should return 401 for invalid JWT', () => { + it('should return 401 for invalid JWT', async () => { vi.stubEnv('SESSION_SECRET', 'test-secret'); req.headers.authorization = 'Bearer invalid-token'; const middleware = requireOAuth(); - middleware(req, res, next); + await middleware(req, res, next); expect(res.status).toHaveBeenCalledWith(401); expect(res.json).toHaveBeenCalledWith({ error: 'Invalid or expired token' }); }); - it('should attach decoded user and call next() for valid JWT', () => { + it('should attach decoded user and call next() for valid JWT', async () => { vi.stubEnv('SESSION_SECRET', 'test-secret'); sessionStore.set('123', 'discord-access-token'); const token = jwt.sign({ userId: '123', username: 'testuser' }, 'test-secret', { @@ -94,7 +94,7 @@ describe('requireOAuth middleware', () => { req.headers.authorization = `Bearer ${token}`; const middleware = requireOAuth(); - middleware(req, res, next); + await middleware(req, res, next); expect(next).toHaveBeenCalled(); expect(req.user).toBeDefined(); @@ -103,7 +103,7 @@ describe('requireOAuth middleware', () => { expect(req.authMethod).toBe('oauth'); }); - it('should return 401 when JWT is valid but server-side session is missing', () => { + it('should return 401 when JWT is valid but server-side session is missing', async () => { vi.stubEnv('SESSION_SECRET', 'test-secret'); // Sign a valid JWT but do NOT populate sessionStore const token = jwt.sign({ userId: '999', username: 'nosession' }, 'test-secret', { @@ -112,14 +112,14 @@ describe('requireOAuth middleware', () => { req.headers.authorization = `Bearer ${token}`; const middleware = requireOAuth(); - middleware(req, res, next); + await middleware(req, res, next); expect(res.status).toHaveBeenCalledWith(401); expect(res.json).toHaveBeenCalledWith({ error: 'Session expired or revoked' }); expect(next).not.toHaveBeenCalled(); }); - it('should return 401 for expired JWT', () => { + it('should return 401 for expired JWT', async () => { vi.stubEnv('SESSION_SECRET', 'test-secret'); const token = jwt.sign({ userId: '123' }, 'test-secret', { algorithm: 'HS256', @@ -128,7 +128,7 @@ describe('requireOAuth middleware', () => { req.headers.authorization = `Bearer ${token}`; const middleware = requireOAuth(); - middleware(req, res, next); + await middleware(req, res, next); expect(res.status).toHaveBeenCalledWith(401); expect(res.json).toHaveBeenCalledWith({ error: 'Invalid or expired token' }); diff --git a/tests/api/routes/config.test.js b/tests/api/routes/config.test.js index b09f09d2..d68ba1bf 100644 --- a/tests/api/routes/config.test.js +++ b/tests/api/routes/config.test.js @@ -650,5 +650,3 @@ describe('validateSingleValue', () => { expect(errors[0]).toContain('must not be null'); }); }); - - diff --git a/tests/api/routes/guilds.test.js b/tests/api/routes/guilds.test.js index ed31e97e..3bae071e 100644 --- a/tests/api/routes/guilds.test.js +++ b/tests/api/routes/guilds.test.js @@ -314,9 +314,7 @@ describe('guilds routes', () => { describe('GET /:id/roles', () => { it('should return guild roles', async () => { - const res = await request(app) - .get('/api/v1/guilds/guild1/roles') - .set('x-api-secret', SECRET); + const res = await request(app).get('/api/v1/guilds/guild1/roles').set('x-api-secret', SECRET); expect(res.status).toBe(200); expect(res.body).toBeInstanceOf(Array); diff --git a/tests/api/utils/configAllowlist.test.js b/tests/api/utils/configAllowlist.test.js index 9d46d0be..51fc2be1 100644 --- a/tests/api/utils/configAllowlist.test.js +++ b/tests/api/utils/configAllowlist.test.js @@ -211,9 +211,7 @@ describe('configAllowlist', () => { }); it('should not strip mask sentinel from non-sensitive fields', () => { - const writes = [ - { path: 'ai.model', value: '••••••••' }, - ]; + const writes = [{ path: 'ai.model', value: '••••••••' }]; const result = stripMaskedWrites(writes); @@ -231,4 +229,4 @@ describe('configAllowlist', () => { expect(result).toEqual([]); }); }); -}); \ No newline at end of file +}); diff --git a/tests/api/utils/sessionStore.test.js b/tests/api/utils/sessionStore.test.js new file mode 100644 index 00000000..caefbf10 --- /dev/null +++ b/tests/api/utils/sessionStore.test.js @@ -0,0 +1,181 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../../src/logger.js', () => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), +})); + +// Mock redisClient so we can control getRedisClient()'s return value +// without spinning up a real Redis connection. +vi.mock('../../../src/api/utils/redisClient.js', () => ({ + getRedisClient: vi.fn().mockReturnValue(null), // default: no Redis + closeRedis: vi.fn().mockResolvedValue(undefined), + _resetRedisClient: vi.fn(), +})); + +/** + * Build a minimal ioredis-compatible mock with inspectable vi.fn() methods. + */ +function buildRedisMock() { + return { + setex: vi.fn().mockResolvedValue('OK'), + get: vi.fn().mockResolvedValue(null), + exists: vi.fn().mockResolvedValue(0), + del: vi.fn().mockResolvedValue(1), + quit: vi.fn().mockResolvedValue('OK'), + on: vi.fn(), + }; +} + +// ────────────────────────────────────────────── +// In-memory (no Redis) path +// ────────────────────────────────────────────── + +describe('SessionStore — in-memory fallback (no REDIS_URL)', () => { + // Import after mock is registered (mocks are hoisted, so this is fine) + let sessionStore; + let getSessionToken; + + beforeEach(async () => { + vi.resetModules(); + // Ensure getRedisClient returns null (in-memory path) + const redisClientMod = await import('../../../src/api/utils/redisClient.js'); + redisClientMod.getRedisClient.mockReturnValue(null); + + const mod = await import('../../../src/api/utils/sessionStore.js'); + sessionStore = mod.sessionStore; + getSessionToken = mod.getSessionToken; + }); + + afterEach(() => { + sessionStore.clear(); + vi.clearAllMocks(); + }); + + it('set() stores a token and get() returns it', () => { + sessionStore.set('user1', 'tok1'); + expect(sessionStore.get('user1')).toBe('tok1'); + }); + + it('get() returns undefined for unknown user', () => { + expect(sessionStore.get('nobody')).toBeUndefined(); + }); + + it('has() returns true when a session exists', () => { + sessionStore.set('user2', 'tok2'); + expect(sessionStore.has('user2')).toBe(true); + }); + + it('has() returns false when no session exists', () => { + expect(sessionStore.has('ghost')).toBe(false); + }); + + it('delete() removes a session', () => { + sessionStore.set('user3', 'tok3'); + sessionStore.delete('user3'); + expect(sessionStore.get('user3')).toBeUndefined(); + }); + + it('cleanup() purges expired entries', () => { + vi.useFakeTimers(); + sessionStore.set('expired-user', 'expired-tok'); + // Advance time beyond the session TTL (1 hour = 3600000ms) + vi.advanceTimersByTime(3_600_001); + sessionStore.cleanup(); + expect(sessionStore.get('expired-user')).toBeUndefined(); + vi.useRealTimers(); + }); + + it('cleanup() leaves non-expired entries intact', () => { + sessionStore.set('live-user', 'live-tok'); + sessionStore.cleanup(); + expect(sessionStore.get('live-user')).toBe('live-tok'); + }); + + it('getSessionToken() returns the access token', async () => { + sessionStore.set('user4', 'tok4'); + expect(await getSessionToken('user4')).toBe('tok4'); + }); + + it('getSessionToken() returns undefined for missing session', async () => { + expect(await getSessionToken('missing')).toBeUndefined(); + }); +}); + +// ────────────────────────────────────────────── +// Redis path +// ────────────────────────────────────────────── + +describe('SessionStore — Redis backend (REDIS_URL configured)', () => { + let sessionStore; + let getSessionToken; + let redisMock; + + beforeEach(async () => { + redisMock = buildRedisMock(); + + vi.resetModules(); + + // Make getRedisClient() return our mock Redis instance + const redisClientMod = await import('../../../src/api/utils/redisClient.js'); + redisClientMod.getRedisClient.mockReturnValue(redisMock); + + const mod = await import('../../../src/api/utils/sessionStore.js'); + sessionStore = mod.sessionStore; + getSessionToken = mod.getSessionToken; + }); + + afterEach(() => { + vi.clearAllMocks(); + vi.restoreAllMocks(); + }); + + it('set() calls redis.setex with the correct key and TTL', async () => { + await sessionStore.set('u1', 'access-tok'); + expect(redisMock.setex).toHaveBeenCalledWith('session:u1', 3600, 'access-tok'); + }); + + it('get() calls redis.get and returns the token', async () => { + redisMock.get.mockResolvedValue('stored-tok'); + const result = await sessionStore.get('u2'); + expect(redisMock.get).toHaveBeenCalledWith('session:u2'); + expect(result).toBe('stored-tok'); + }); + + it('get() returns null for a missing key', async () => { + redisMock.get.mockResolvedValue(null); + const result = await sessionStore.get('nobody'); + expect(result).toBeNull(); + }); + + it('has() returns true when Redis reports the key exists', async () => { + redisMock.exists.mockResolvedValue(1); + const result = await sessionStore.has('u3'); + expect(redisMock.exists).toHaveBeenCalledWith('session:u3'); + expect(result).toBe(true); + }); + + it('has() returns false when Redis reports the key is absent', async () => { + redisMock.exists.mockResolvedValue(0); + const result = await sessionStore.has('ghost'); + expect(result).toBe(false); + }); + + it('delete() calls redis.del with the correct key', async () => { + await sessionStore.delete('u4'); + expect(redisMock.del).toHaveBeenCalledWith('session:u4'); + }); + + it('cleanup() is a no-op when Redis is active', () => { + expect(() => sessionStore.cleanup()).not.toThrow(); + expect(redisMock.setex).not.toHaveBeenCalled(); + expect(redisMock.del).not.toHaveBeenCalled(); + }); + + it('getSessionToken() returns the token from Redis', async () => { + redisMock.get.mockResolvedValue('redis-tok'); + const result = await getSessionToken('u5'); + expect(result).toBe('redis-tok'); + }); +}); diff --git a/tests/api/utils/validateConfigPatch.test.js b/tests/api/utils/validateConfigPatch.test.js index a4b5f4c3..570e0003 100644 --- a/tests/api/utils/validateConfigPatch.test.js +++ b/tests/api/utils/validateConfigPatch.test.js @@ -277,4 +277,4 @@ describe('validateConfigPatch', () => { expect(result.status).toBe(400); }); }); -}); \ No newline at end of file +}); diff --git a/tests/api/ws/logStream.test.js b/tests/api/ws/logStream.test.js index ce9460a9..311d0f8c 100644 --- a/tests/api/ws/logStream.test.js +++ b/tests/api/ws/logStream.test.js @@ -2,8 +2,12 @@ import { createHmac, randomBytes } from 'node:crypto'; import http from 'node:http'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import WebSocket from 'ws'; +import { + getAuthenticatedClientCount, + setupLogStream, + stopLogStream, +} from '../../../src/api/ws/logStream.js'; import { WebSocketTransport } from '../../../src/transports/websocket.js'; -import { setupLogStream, stopLogStream, getAuthenticatedClientCount } from '../../../src/api/ws/logStream.js'; const TEST_SECRET = 'test-api-secret-for-ws'; @@ -199,7 +203,12 @@ describe('WebSocket Log Stream', () => { await authenticate(ws, mq); transport.log( - { level: 'info', message: 'real-time log', timestamp: '2026-01-01T00:00:00Z', module: 'test' }, + { + level: 'info', + message: 'real-time log', + timestamp: '2026-01-01T00:00:00Z', + module: 'test', + }, vi.fn(), ); @@ -230,7 +239,10 @@ describe('WebSocket Log Stream', () => { expect(filterOk.type).toBe('filter_ok'); expect(filterOk.filter.level).toBe('error'); - transport.log({ level: 'error', message: 'error log', timestamp: '2026-01-01T00:00:00Z' }, vi.fn()); + transport.log( + { level: 'error', message: 'error log', timestamp: '2026-01-01T00:00:00Z' }, + vi.fn(), + ); const logMsg = await mq.next(); expect(logMsg.level).toBe('error'); expect(logMsg.message).toBe('error log'); @@ -244,8 +256,14 @@ describe('WebSocket Log Stream', () => { await mq.next(); // filter_ok // Info log should be filtered; send error right after to prove it works - transport.log({ level: 'info', message: 'filtered', timestamp: '2026-01-01T00:00:00Z' }, vi.fn()); - transport.log({ level: 'error', message: 'arrives', timestamp: '2026-01-01T00:00:00Z' }, vi.fn()); + transport.log( + { level: 'info', message: 'filtered', timestamp: '2026-01-01T00:00:00Z' }, + vi.fn(), + ); + transport.log( + { level: 'error', message: 'arrives', timestamp: '2026-01-01T00:00:00Z' }, + vi.fn(), + ); const logMsg = await mq.next(); expect(logMsg.message).toBe('arrives'); diff --git a/tests/modules/triage-prompt.test.js b/tests/modules/triage-prompt.test.js index e68bd565..60a3425d 100644 --- a/tests/modules/triage-prompt.test.js +++ b/tests/modules/triage-prompt.test.js @@ -371,4 +371,4 @@ describe('triage-prompt', () => { expect(result).toContain('Targets: ["msg1","msg2"]'); }); }); -}); \ No newline at end of file +}); diff --git a/tests/modules/triage-respond.test.js b/tests/modules/triage-respond.test.js index db9599b6..23f77f81 100644 --- a/tests/modules/triage-respond.test.js +++ b/tests/modules/triage-respond.test.js @@ -1,4 +1,4 @@ -import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { buildStatsAndLog, fetchChannelContext, @@ -557,9 +557,18 @@ describe('triage-respond', () => { }, }; - const result = await buildStatsAndLog({}, {}, {}, snapshot, classification, 0, mockClient, 'channel1'); + const result = await buildStatsAndLog( + {}, + {}, + {}, + snapshot, + classification, + 0, + mockClient, + 'channel1', + ); expect(result.stats.userId).toBe(null); }); }); -}); \ No newline at end of file +}); diff --git a/tests/sentry.test.js b/tests/sentry.test.js index 569e9cf5..8ee74f71 100644 --- a/tests/sentry.test.js +++ b/tests/sentry.test.js @@ -2,7 +2,7 @@ * Tests for Sentry integration module */ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; describe('sentry module', () => { beforeEach(() => { diff --git a/tests/transports/websocket.test.js b/tests/transports/websocket.test.js index 422bd4cc..fb3daddf 100644 --- a/tests/transports/websocket.test.js +++ b/tests/transports/websocket.test.js @@ -61,7 +61,10 @@ describe('WebSocketTransport', () => { transport.addClient(ws); const callback = vi.fn(); - transport.log({ level: 'info', message: 'hello world', timestamp: '2026-01-01T00:00:00Z' }, callback); + transport.log( + { level: 'info', message: 'hello world', timestamp: '2026-01-01T00:00:00Z' }, + callback, + ); expect(ws.send).toHaveBeenCalledOnce(); const sent = JSON.parse(ws.send.mock.calls[0][0]); @@ -96,7 +99,9 @@ describe('WebSocketTransport', () => { it('should handle send errors gracefully', () => { const ws = createMockWs(); - ws.send.mockImplementation(() => { throw new Error('send failed'); }); + ws.send.mockImplementation(() => { + throw new Error('send failed'); + }); transport.addClient(ws); const callback = vi.fn(); @@ -108,13 +113,16 @@ describe('WebSocketTransport', () => { const ws = createMockWs(); transport.addClient(ws); - transport.log({ - level: 'info', - message: 'test', - timestamp: '2026-01-01T00:00:00Z', - module: 'api', - userId: '123', - }, vi.fn()); + transport.log( + { + level: 'info', + message: 'test', + timestamp: '2026-01-01T00:00:00Z', + module: 'api', + userId: '123', + }, + vi.fn(), + ); const sent = JSON.parse(ws.send.mock.calls[0][0]); expect(sent.metadata.module).toBe('api'); @@ -129,12 +137,15 @@ describe('WebSocketTransport', () => { const circular = {}; circular.self = circular; - transport.log({ - level: 'info', - message: 'test', - timestamp: '2026-01-01T00:00:00Z', - data: circular, - }, vi.fn()); + transport.log( + { + level: 'info', + message: 'test', + timestamp: '2026-01-01T00:00:00Z', + data: circular, + }, + vi.fn(), + ); // Should still send — falls back to empty metadata expect(ws.send).toHaveBeenCalledOnce(); @@ -161,14 +172,20 @@ describe('WebSocketTransport', () => { it('should filter by module', () => { const filter = { module: 'api' }; - expect(transport.passesFilter({ level: 'info', message: 'test', module: 'api' }, filter)).toBe(true); - expect(transport.passesFilter({ level: 'info', message: 'test', module: 'bot' }, filter)).toBe(false); + expect( + transport.passesFilter({ level: 'info', message: 'test', module: 'api' }, filter), + ).toBe(true); + expect( + transport.passesFilter({ level: 'info', message: 'test', module: 'bot' }, filter), + ).toBe(false); }); it('should filter by search (case-insensitive)', () => { const filter = { search: 'ERROR' }; - expect(transport.passesFilter({ level: 'info', message: 'An error occurred' }, filter)).toBe(true); + expect(transport.passesFilter({ level: 'info', message: 'An error occurred' }, filter)).toBe( + true, + ); expect(transport.passesFilter({ level: 'info', message: 'All good' }, filter)).toBe(false); }); @@ -176,11 +193,17 @@ describe('WebSocketTransport', () => { const filter = { level: 'warn', module: 'api' }; // Passes both - expect(transport.passesFilter({ level: 'error', message: 'test', module: 'api' }, filter)).toBe(true); + expect( + transport.passesFilter({ level: 'error', message: 'test', module: 'api' }, filter), + ).toBe(true); // Fails level - expect(transport.passesFilter({ level: 'info', message: 'test', module: 'api' }, filter)).toBe(false); + expect( + transport.passesFilter({ level: 'info', message: 'test', module: 'api' }, filter), + ).toBe(false); // Fails module - expect(transport.passesFilter({ level: 'error', message: 'test', module: 'bot' }, filter)).toBe(false); + expect( + transport.passesFilter({ level: 'error', message: 'test', module: 'bot' }, filter), + ).toBe(false); }); it('should apply per-client filters during broadcast', () => { @@ -194,13 +217,19 @@ describe('WebSocketTransport', () => { transport.addClient(wsErrorOnly); // Send an info-level log - transport.log({ level: 'info', message: 'info msg', timestamp: '2026-01-01T00:00:00Z' }, vi.fn()); + transport.log( + { level: 'info', message: 'info msg', timestamp: '2026-01-01T00:00:00Z' }, + vi.fn(), + ); expect(wsAll.send).toHaveBeenCalledOnce(); expect(wsErrorOnly.send).not.toHaveBeenCalled(); // Send an error-level log - transport.log({ level: 'error', message: 'error msg', timestamp: '2026-01-01T00:00:00Z' }, vi.fn()); + transport.log( + { level: 'error', message: 'error msg', timestamp: '2026-01-01T00:00:00Z' }, + vi.fn(), + ); expect(wsAll.send).toHaveBeenCalledTimes(2); expect(wsErrorOnly.send).toHaveBeenCalledOnce();