diff --git a/libraries/validation-tools/testUtils.js b/libraries/validation-tools/testUtils.js index 66246031fd..089ce7227a 100644 --- a/libraries/validation-tools/testUtils.js +++ b/libraries/validation-tools/testUtils.js @@ -1,3 +1,5 @@ +const assert = require('node:assert/strict') + function asZodError(...def) { return { name: 'ZodError', @@ -5,4 +7,82 @@ function asZodError(...def) { } } -module.exports = { asZodError } +/** + * Parses (if needed) a validation-error response body and asserts that it + * matches the given status and names the given field. + * + * handleValidationError (see ./handleValidationError.js) responds with + * `{ error: fromError(err.zodError).toString(), statusCode }`, and + * fromError's message includes the offending field's dotted schema path + * (e.g. `at "params.projectId"`, or the failing key name for an + * unrecognized key), so asserting the field name appears in `body.error` + * pins the test to the intended failure rather than merely the status code. + * + * @param {string|{statusCode: number, error: string}} rawOrParsedBody - the + * response body, either as raw JSON text or already parsed into an object + * (some HTTP client wrappers pre-parse the body for the caller) + * @param {number} status + * @param {string} fieldSubstring + */ +function assertValidationErrorBody(rawOrParsedBody, status, fieldSubstring) { + assert.ok(rawOrParsedBody != null, 'expected a response body') + const body = + typeof rawOrParsedBody === 'string' + ? JSON.parse(rawOrParsedBody) + : rawOrParsedBody + assert.equal(body.statusCode, status) + assert.equal(typeof body.error, 'string') + assert.ok( + body.error.includes(fieldSubstring), + `expected error message ${JSON.stringify(body.error)} to include ${JSON.stringify(fieldSubstring)}` + ) +} + +/** + * Asserts that a request was actually rejected by the zod validation layer + * because of the specific field named in `fieldSubstring` -- not just any + * error/response that happens to produce the same status code. See + * assertValidationErrorBody() above. + * + * @param {any} errorOrResult - either the object thrown/rejected by a failed + * request, e.g. a RequestFailedError from @overleaf/fetch-utils (exposes + * `.response.status` and `.body` as raw text), or a resolved + * `{ response, body }` pair from an HTTP client wrapper that never rejects + * on non-2xx responses and has already parsed `body` from JSON. Either way + * expected to expose `.response.status` and `.body` + * @param {number} status - the expected HTTP status code + * @param {string} fieldSubstring - a substring expected in the error + * message identifying the rejected field (e.g. a param/body/query key) + */ +function expectValidationError(errorOrResult, status, fieldSubstring) { + assert.equal( + errorOrResult && errorOrResult.response && errorOrResult.response.status, + status, + `expected response.status === ${status}` + ) + assertValidationErrorBody(errorOrResult.body, status, fieldSubstring) +} + +/** + * Same as expectValidationError, but for a response object that exposes + * `statusCode` and `body` directly rather than nested under `.response` + * (e.g. a raw node:http response). + * + * @param {any} res - expected to expose `.statusCode` and `.body` directly + * @param {number} status + * @param {string} fieldSubstring + */ +function expectValidationErrorRaw(res, status, fieldSubstring) { + assert.equal( + res && res.statusCode, + status, + `expected a response with statusCode === ${status}` + ) + assertValidationErrorBody(res.body, status, fieldSubstring) +} + +module.exports = { + asZodError, + expectValidationError, + expectValidationErrorRaw, +} diff --git a/services/chat/app.js b/services/chat/app.js index 691b4ceda5..fbf4abce84 100644 --- a/services/chat/app.js +++ b/services/chat/app.js @@ -1,26 +1,142 @@ // Metrics must be initialized before importing anything else import '@overleaf/metrics/initialize.js' +import metrics from '@overleaf/metrics' import logger from '@overleaf/logger' import settings from '@overleaf/settings' +import express from 'express' +import { handleValidationError } from '@overleaf/validation-tools' import { mongoClient } from './app/js/mongodb.js' -import { createServer } from './app/js/server.js' +import * as MessageHttpController from './app/js/Features/Messages/MessageHttpController.js' +import { expressify } from './app/js/util/promises.js' + +logger.initialize('chat') +metrics.open_sockets.monitor() +metrics.leaked_sockets.monitor(logger) + +const app = express() + +app.use(metrics.http.monitor(logger)) +metrics.injectMetricsRoute(app) + +app.use(express.json()) + +app.get( + '/project/:projectId/messages', + expressify(MessageHttpController.getGlobalMessages) +) +app.post( + '/project/:projectId/messages', + expressify(MessageHttpController.sendGlobalMessage) +) +app.get( + '/project/:projectId/messages/:messageId', + expressify(MessageHttpController.getGlobalMessage) +) +app.delete( + '/project/:projectId/messages/:messageId', + expressify(MessageHttpController.deleteGlobalMessage) +) +app.post( + '/project/:projectId/messages/:messageId/edit', + expressify(MessageHttpController.editGlobalMessage) +) +app.post( + '/project/:projectId/thread/:threadId/messages', + expressify(MessageHttpController.sendMessage) +) +app.get( + '/project/:projectId/thread/:threadId/messages/:messageId', + expressify(MessageHttpController.getThreadMessage) +) +app.delete( + '/project/:projectId/thread/:threadId/messages/:messageId', + expressify(MessageHttpController.deleteMessage) +) +app.post( + '/project/:projectId/thread/:threadId/messages/:messageId/edit', + expressify(MessageHttpController.editMessage) +) +app.delete( + '/project/:projectId/thread/:threadId/user/:userId/messages/:messageId', + expressify(MessageHttpController.deleteUserMessage) +) +app.post( + '/project/:projectId/thread/:threadId/resolve', + expressify(MessageHttpController.resolveThread) +) +app.post( + '/project/:projectId/thread/:threadId/reopen', + expressify(MessageHttpController.reopenThread) +) +app.get( + '/project/:projectId/thread/:threadId', + expressify(MessageHttpController.getThread) +) +app.delete( + '/project/:projectId/thread/:threadId', + expressify(MessageHttpController.deleteThread) +) +app.get( + '/project/:projectId/threads', + expressify(MessageHttpController.getThreads) +) +app.get( + '/project/:projectId/resolved-thread-ids', + expressify(MessageHttpController.getResolvedThreadIds) +) +app.delete( + '/project/:projectId', + expressify(MessageHttpController.destroyProject) +) +app.post( + '/project/:projectId/duplicate-comment-threads', + expressify(MessageHttpController.duplicateCommentThreads) +) +app.post( + '/project/:projectId/generate-thread-data', + expressify(MessageHttpController.generateThreadData) +) +app.post( + '/project/:projectId/clone-comment-threads', + expressify(MessageHttpController.cloneCommentThreads) +) +// express also serves HEAD /status via this GET route +app.get('/status', expressify(MessageHttpController.getStatus)) + +// Return a 404 for unknown routes +app.use((req, res) => { + res.status(404).json({ message: `Not found` }) +}) + +app.use(handleValidationError) + +// Handle any unexpected errors +app.use((err, req, res, next) => { + logger.error({ err, req }, 'request errored') + res.status(500).json({ message: `Internal error: ${err.message}` }) +}) const port = settings.internal.chat.port const host = settings.internal.chat.host -mongoClient - .connect() - .then(async () => { - const { server } = await createServer() - server.listen(port, host, function (err) { - if (err) { - logger.fatal({ err }, `Cannot bind to ${host}:${port}. Exiting.`) - process.exit(1) - } - logger.debug(`Chat starting up, listening on ${host}:${port}`) + +if (import.meta.main) { + // Called directly + mongoClient + .connect() + .then(() => { + app.listen(port, host, function (err) { + if (err) { + logger.fatal({ err }, `Cannot bind to ${host}:${port}. Exiting.`) + process.exit(1) + } + logger.debug(`Chat starting up, listening on ${host}:${port}`) + }) }) - }) - .catch(err => { - logger.fatal({ err }, 'Cannot connect to mongo. Exiting.') - process.exit(1) - }) + .catch(err => { + logger.fatal({ err }, 'Cannot connect to mongo. Exiting.') + process.exit(1) + }) +} + +export default app diff --git a/services/chat/app/js/Features/Messages/MessageHttpController.js b/services/chat/app/js/Features/Messages/MessageHttpController.js index d5f145f0f7..0a3341d563 100644 --- a/services/chat/app/js/Features/Messages/MessageHttpController.js +++ b/services/chat/app/js/Features/Messages/MessageHttpController.js @@ -1,136 +1,28 @@ import logger from '@overleaf/logger' +import { parseReq } from '@overleaf/validation-tools' +import { promiseMapWithLimit } from '@overleaf/promise-utils' import * as MessageManager from './MessageManager.js' import * as MessageFormatter from './MessageFormatter.js' import * as ThreadManager from '../Threads/ThreadManager.js' -import { ObjectId } from '../../mongodb.js' -import { promiseMapWithLimit } from '@overleaf/promise-utils' +import * as Schemas from './MessageHttpSchemas.js' -const DEFAULT_MESSAGE_LIMIT = 50 -const MAX_MESSAGE_LENGTH = 10 * 1024 // 10kb, about 1,500 words - -function readContext(context, req) { - req.body = context.requestBody - req.params = context.params.path - req.query = context.params.query - if (typeof req.params.projectId !== 'undefined') { - if (!ObjectId.isValid(req.params.projectId)) { - context.res.status(400).setBody('Invalid projectId') - } - } - if (typeof req.params.threadId !== 'undefined') { - if (!ObjectId.isValid(req.params.threadId)) { - context.res.status(400).setBody('Invalid threadId') - } - } -} - -/** - * @param context - * @param {(req: unknown, res: unknown) => Promise} ControllerMethod - * @returns {Promise<*>} - */ -export async function callMessageHttpController(context, ControllerMethod) { - const req = {} - readContext(context, req) - if (context.res.statusCode !== 400) { - return await ControllerMethod(req, context.res) - } else { - return context.res.body - } -} - -export async function getGlobalMessages(context) { - return await callMessageHttpController(context, _getGlobalMessages) -} - -export async function getGlobalMessage(context) { - return await callMessageHttpController(context, _getGlobalMessage) -} - -export async function sendGlobalMessage(context) { - return await callMessageHttpController(context, _sendGlobalMessage) -} - -export async function sendMessage(context) { - return await callMessageHttpController(context, _sendThreadMessage) -} - -export async function getThreads(context) { - return await callMessageHttpController(context, _getAllThreads) -} - -export async function getThread(context) { - return await callMessageHttpController(context, _getThread) -} - -export async function getThreadMessage(context) { - return await callMessageHttpController(context, _getThreadMessage) -} - -export async function resolveThread(context) { - return await callMessageHttpController(context, _resolveThread) -} - -export async function reopenThread(context) { - return await callMessageHttpController(context, _reopenThread) -} - -export async function deleteThread(context) { - return await callMessageHttpController(context, _deleteThread) -} - -export async function editMessage(context) { - return await callMessageHttpController(context, _editMessage) -} - -export async function editGlobalMessage(context) { - return await callMessageHttpController(context, _editGlobalMessage) -} - -export async function deleteMessage(context) { - return await callMessageHttpController(context, _deleteMessage) -} - -export async function deleteUserMessage(context) { - return await callMessageHttpController(context, _deleteUserMessage) -} - -export async function deleteGlobalMessage(context) { - return await callMessageHttpController(context, _deleteGlobalMessage) -} - -export async function getResolvedThreadIds(context) { - return await callMessageHttpController(context, _getResolvedThreadIds) -} - -export async function destroyProject(context) { - return await callMessageHttpController(context, _destroyProject) -} - -export async function duplicateCommentThreads(context) { - return await callMessageHttpController(context, _duplicateCommentThreads) -} - -export async function generateThreadData(context) { - return await callMessageHttpController(context, _generateThreadData) -} - -export async function cloneCommentThreads(context) { - return await callMessageHttpController(context, _cloneCommentThreads) -} - -export async function getStatus(context) { +export async function getStatus(req, res) { const message = 'chat is alive' - context.res.status(200).setBody(message) - return message + res.send(message) } -const _getGlobalMessages = async (req, res) => { - await _getMessages(ThreadManager.GLOBAL_THREAD, req, res) +export async function getGlobalMessages(req, res) { + const { + params: { projectId }, + query: { before, limit }, + } = parseReq(req, Schemas.getGlobalMessages) + await _getMessages(projectId, ThreadManager.GLOBAL_THREAD, before, limit, res) } -const _getGlobalMessage = async (req, res) => { - const { projectId, messageId } = req.params +export async function getGlobalMessage(req, res) { + const { + params: { projectId, messageId }, + } = parseReq(req, Schemas.getGlobalMessage) logger.debug({ projectId, messageId }, 'getting single global message') try { const room = await ThreadManager.findThread( @@ -141,23 +33,25 @@ const _getGlobalMessage = async (req, res) => { const message = await MessageManager.getMessage(room._id, messageId) const formattedMsg = MessageFormatter.formatMessageForClientSide(message) - res.status(200).setBody(formattedMsg) + res.json(formattedMsg) } catch (error) { if ( error instanceof ThreadManager.MissingThreadError || error instanceof MessageManager.MissingMessageError ) { - res.status(404) + res.sendStatus(404) return } throw error } } -async function _sendGlobalMessage(req, res) { - const { user_id: userId, content } = req.body - const { projectId } = req.params - return await _sendMessage( +export async function sendGlobalMessage(req, res) { + const { + params: { projectId }, + body: { user_id: userId, content }, + } = parseReq(req, Schemas.sendGlobalMessage) + await _sendMessage( userId, projectId, content, @@ -166,14 +60,18 @@ async function _sendGlobalMessage(req, res) { ) } -async function _sendThreadMessage(req, res) { - const { user_id: userId, content } = req.body - const { projectId, threadId } = req.params - return await _sendMessage(userId, projectId, content, threadId, res) +export async function sendMessage(req, res) { + const { + params: { projectId, threadId }, + body: { user_id: userId, content }, + } = parseReq(req, Schemas.sendMessage) + await _sendMessage(userId, projectId, content, threadId, res) } -const _getAllThreads = async (req, res) => { - const { projectId } = req.params +export async function getThreads(req, res) { + const { + params: { projectId }, + } = parseReq(req, Schemas.getThreads) logger.debug({ projectId }, 'getting all threads') const rooms = await ThreadManager.findAllThreadRooms(projectId) const roomIds = rooms.map(r => r._id) @@ -182,9 +80,11 @@ const _getAllThreads = async (req, res) => { res.json(threads) } -const _generateThreadData = async (req, res) => { - const { projectId } = req.params - const { threads } = req.body +export async function generateThreadData(req, res) { + const { + params: { projectId }, + body: { threads }, + } = parseReq(req, Schemas.generateThreadData) logger.debug({ projectId }, 'getting all threads') const rooms = await ThreadManager.findThreadsById(projectId, threads) const roomIds = rooms.map(r => r._id) @@ -194,8 +94,10 @@ const _generateThreadData = async (req, res) => { res.json(threadData) } -const _getThread = async (req, res) => { - const { projectId, threadId } = req.params +export async function getThread(req, res) { + const { + params: { projectId, threadId }, + } = parseReq(req, Schemas.getThread) logger.debug({ projectId, threadId }, 'getting specific thread') try { const room = await ThreadManager.findThread(projectId, threadId) @@ -204,21 +106,23 @@ const _getThread = async (req, res) => { const thread = threads[threadId] || null if (!thread) { - res.status(404) + res.sendStatus(404) return } res.json(thread) } catch (error) { if (error instanceof ThreadManager.MissingThreadError) { - res.status(404) + res.sendStatus(404) return } throw error } } -const _getThreadMessage = async (req, res) => { - const { projectId, threadId, messageId } = req.params +export async function getThreadMessage(req, res) { + const { + params: { projectId, threadId, messageId }, + } = parseReq(req, Schemas.getThreadMessage) logger.debug( { projectId, threadId, messageId }, 'getting single thread message' @@ -228,45 +132,53 @@ const _getThreadMessage = async (req, res) => { const message = await MessageManager.getMessage(room._id, messageId) const formattedMsg = MessageFormatter.formatMessageForClientSide(message) - res.status(200).setBody(formattedMsg) + res.json(formattedMsg) } catch (error) { if ( error instanceof ThreadManager.MissingThreadError || error instanceof MessageManager.MissingMessageError ) { - res.status(404) + res.sendStatus(404) return } throw error } } -const _resolveThread = async (req, res) => { - const { projectId, threadId } = req.params - const { user_id: userId } = req.body +export async function resolveThread(req, res) { + const { + params: { projectId, threadId }, + body: { user_id: userId }, + } = parseReq(req, Schemas.resolveThread) logger.debug({ userId, projectId, threadId }, 'marking thread as resolved') await ThreadManager.resolveThread(projectId, threadId, userId) - res.status(204) + res.sendStatus(204) } -const _reopenThread = async (req, res) => { - const { projectId, threadId } = req.params +export async function reopenThread(req, res) { + const { + params: { projectId, threadId }, + } = parseReq(req, Schemas.reopenThread) logger.debug({ projectId, threadId }, 'reopening thread') await ThreadManager.reopenThread(projectId, threadId) - res.status(204) + res.sendStatus(204) } -const _deleteThread = async (req, res) => { - const { projectId, threadId } = req.params +export async function deleteThread(req, res) { + const { + params: { projectId, threadId }, + } = parseReq(req, Schemas.deleteThread) logger.debug({ projectId, threadId }, 'deleting thread') const roomId = await ThreadManager.deleteThread(projectId, threadId) await MessageManager.deleteAllMessagesInRoom(roomId) - res.status(204) + res.sendStatus(204) } -const _editMessage = async (req, res) => { - const { content, userId } = req.body - const { projectId, threadId, messageId } = req.params +export async function editMessage(req, res) { + const { + params: { projectId, threadId, messageId }, + body: { content, userId }, + } = parseReq(req, Schemas.editMessage) logger.debug({ projectId, threadId, messageId, content }, 'editing message') const room = await ThreadManager.findOrCreateThread(projectId, threadId) const found = await MessageManager.updateMessage( @@ -277,15 +189,17 @@ const _editMessage = async (req, res) => { Date.now() ) if (!found) { - res.status(404) + res.sendStatus(404) return } - res.status(204) + res.sendStatus(204) } -const _editGlobalMessage = async (req, res) => { - const { content, userId } = req.body - const { projectId, messageId } = req.params +export async function editGlobalMessage(req, res) { + const { + params: { projectId, messageId }, + body: { content, userId }, + } = parseReq(req, Schemas.editGlobalMessage) logger.debug({ projectId, messageId, content }, 'editing global message') const room = await ThreadManager.findOrCreateThread( projectId, @@ -299,45 +213,55 @@ const _editGlobalMessage = async (req, res) => { Date.now() ) if (!found) { - res.status(404) + res.sendStatus(404) return } - res.status(204) + res.sendStatus(204) } -const _deleteMessage = async (req, res) => { - const { projectId, threadId, messageId } = req.params +export async function deleteMessage(req, res) { + const { + params: { projectId, threadId, messageId }, + } = parseReq(req, Schemas.deleteMessage) logger.debug({ projectId, threadId, messageId }, 'deleting message') const room = await ThreadManager.findOrCreateThread(projectId, threadId) await MessageManager.deleteMessage(room._id, messageId) - res.status(204) + res.sendStatus(204) } -const _deleteUserMessage = async (req, res) => { - const { projectId, threadId, userId, messageId } = req.params +export async function deleteUserMessage(req, res) { + const { + params: { projectId, threadId, userId, messageId }, + } = parseReq(req, Schemas.deleteUserMessage) const room = await ThreadManager.findOrCreateThread(projectId, threadId) await MessageManager.deleteUserMessage(userId, room._id, messageId) - res.status(204) + res.sendStatus(204) } -const _deleteGlobalMessage = async (req, res) => { - const { projectId, messageId } = req.params +export async function deleteGlobalMessage(req, res) { + const { + params: { projectId, messageId }, + } = parseReq(req, Schemas.deleteGlobalMessage) const room = await ThreadManager.findOrCreateThread( projectId, ThreadManager.GLOBAL_THREAD ) await MessageManager.deleteMessage(room._id, messageId) - res.status(204) + res.sendStatus(204) } -const _getResolvedThreadIds = async (req, res) => { - const { projectId } = req.params +export async function getResolvedThreadIds(req, res) { + const { + params: { projectId }, + } = parseReq(req, Schemas.getResolvedThreadIds) const resolvedThreadIds = await ThreadManager.getResolvedThreadIds(projectId) res.json({ resolvedThreadIds }) } -const _destroyProject = async (req, res) => { - const { projectId } = req.params +export async function destroyProject(req, res) { + const { + params: { projectId }, + } = parseReq(req, Schemas.destroyProject) logger.debug({ projectId }, 'destroying project') const rooms = await ThreadManager.findAllThreadRoomsAndGlobalThread(projectId) const roomIds = rooms.map(r => r._id) @@ -345,25 +269,10 @@ const _destroyProject = async (req, res) => { await MessageManager.deleteAllMessagesInRooms(roomIds) logger.debug({ projectId }, 'deleting all threads in project') await ThreadManager.deleteAllThreadsInProject(projectId) - res.status(204) + res.sendStatus(204) } async function _sendMessage(userId, projectId, content, clientThreadId, res) { - if (!ObjectId.isValid(userId)) { - const message = 'Invalid userId' - res.status(400).setBody(message) - return message - } - if (!content) { - const message = 'No content provided' - res.status(400).setBody(message) - return message - } - if (content.length > MAX_MESSAGE_LENGTH) { - const message = `Content too long (> ${MAX_MESSAGE_LENGTH} bytes)` - res.status(400).setBody(message) - return message - } logger.debug( { clientThreadId, projectId, userId, content }, 'new message received' @@ -381,22 +290,10 @@ async function _sendMessage(userId, projectId, content, clientThreadId, res) { message = MessageFormatter.formatMessageForClientSide(message) message.room_id = projectId - res.status(201).setBody(message) + res.status(201).json(message) } -async function _getMessages(clientThreadId, req, res) { - let before, limit - const { projectId } = req.params - if (req.query.before) { - before = parseInt(req.query.before, 10) - } else { - before = null - } - if (req.query.limit) { - limit = parseInt(req.query.limit, 10) - } else { - limit = DEFAULT_MESSAGE_LIMIT - } +async function _getMessages(projectId, clientThreadId, before, limit, res) { logger.debug( { limit, before, projectId, clientThreadId }, 'get message request received' @@ -413,12 +310,14 @@ async function _getMessages(clientThreadId, req, res) { let messages = await MessageManager.getMessages(threadObjectId, limit, before) messages = MessageFormatter.formatMessagesForClientSide(messages) logger.debug({ projectId, messages }, 'got messages') - res.status(200).setBody(messages) + res.json(messages) } -async function _duplicateCommentThreads(req, res) { - const { projectId } = req.params - const { threads } = req.body +export async function duplicateCommentThreads(req, res) { + const { + params: { projectId }, + body: { threads }, + } = parseReq(req, Schemas.duplicateCommentThreads) const result = {} for (const id of threads) { logger.debug({ projectId, thread: id }, 'duplicating thread') @@ -442,9 +341,11 @@ async function _duplicateCommentThreads(req, res) { res.json({ newThreads: result }) } -async function _cloneCommentThreads(req, res) { - const { projectId: sourceProjectId } = req.params - const { targetProjectId } = req.body +export async function cloneCommentThreads(req, res) { + const { + params: { projectId: sourceProjectId }, + body: { targetProjectId }, + } = parseReq(req, Schemas.cloneCommentThreads) const rooms = await ThreadManager.cloneThreads( sourceProjectId, targetProjectId @@ -452,5 +353,5 @@ async function _cloneCommentThreads(req, res) { await promiseMapWithLimit(10, rooms, async ({ from, to }) => { await MessageManager.duplicateRoomToOtherRoom(from, to) }) - res.status(204) + res.sendStatus(204) } diff --git a/services/chat/app/js/Features/Messages/MessageHttpSchemas.js b/services/chat/app/js/Features/Messages/MessageHttpSchemas.js new file mode 100644 index 0000000000..e917317d70 --- /dev/null +++ b/services/chat/app/js/Features/Messages/MessageHttpSchemas.js @@ -0,0 +1,98 @@ +import { z, zz } from '@overleaf/validation-tools' + +export const MAX_MESSAGE_LENGTH = 10 * 1024 // 10kb, about 1,500 words +const DEFAULT_MESSAGE_LIMIT = 50 + +const projectParams = z.object({ projectId: zz.objectId() }) +const threadParams = projectParams.extend({ threadId: zz.objectId() }) +const messageParams = projectParams.extend({ messageId: zz.objectId() }) +const threadMessageParams = threadParams.extend({ messageId: zz.objectId() }) +const userMessageParams = threadMessageParams.extend({ userId: zz.objectId() }) + +const messageContent = z + .string({ error: 'No content provided' }) + .min(1, 'No content provided') + .max(MAX_MESSAGE_LENGTH, `Content too long (> ${MAX_MESSAGE_LENGTH} bytes)`) + +const sendMessageBody = z.strictObject({ + user_id: zz.objectId(), + content: messageContent, +}) + +// web sends `userId` (camelCase) when editing, unlike `user_id` when sending. +const editMessageBody = z.strictObject({ + content: messageContent, + userId: zz.objectId().optional(), +}) + +const threadsBody = z.strictObject({ threads: z.array(zz.objectId()) }) + +export const getGlobalMessages = z.object({ + params: projectParams, + query: z.strictObject({ + before: z.coerce.number().int().optional(), + limit: z.coerce.number().int().default(DEFAULT_MESSAGE_LIMIT), + }), +}) + +export const sendGlobalMessage = z.object({ + params: projectParams, + body: sendMessageBody, +}) + +export const getGlobalMessage = z.object({ params: messageParams }) + +export const deleteGlobalMessage = z.object({ params: messageParams }) + +export const editGlobalMessage = z.object({ + params: messageParams, + body: editMessageBody, +}) + +export const sendMessage = z.object({ + params: threadParams, + body: sendMessageBody, +}) + +export const getThreadMessage = z.object({ params: threadMessageParams }) + +export const deleteMessage = z.object({ params: threadMessageParams }) + +export const editMessage = z.object({ + params: threadMessageParams, + body: editMessageBody, +}) + +export const deleteUserMessage = z.object({ params: userMessageParams }) + +export const resolveThread = z.object({ + params: threadParams, + body: z.strictObject({ user_id: zz.objectId() }), +}) + +export const reopenThread = z.object({ params: threadParams }) + +export const getThread = z.object({ params: threadParams }) + +export const deleteThread = z.object({ params: threadParams }) + +export const getThreads = z.object({ params: projectParams }) + +export const getResolvedThreadIds = z.object({ params: projectParams }) + +export const destroyProject = z.object({ params: projectParams }) + +export const duplicateCommentThreads = z.object({ + params: projectParams, + body: threadsBody, +}) + +export const generateThreadData = z.object({ + params: projectParams, + body: threadsBody, +}) + +export const cloneCommentThreads = z.object({ + params: projectParams, + body: z.strictObject({ targetProjectId: zz.objectId() }), +}) diff --git a/services/chat/app/js/server.js b/services/chat/app/js/server.js deleted file mode 100644 index 80970fc22e..0000000000 --- a/services/chat/app/js/server.js +++ /dev/null @@ -1,51 +0,0 @@ -import http from 'node:http' -import metrics from '@overleaf/metrics' -import logger from '@overleaf/logger' -import express from 'express' -import exegesisExpress from 'exegesis-express' -import path from 'node:path' -import { fileURLToPath } from 'node:url' -import * as messagesController from './Features/Messages/MessageHttpController.js' - -const __dirname = fileURLToPath(new URL('.', import.meta.url)) - -logger.initialize('chat') -metrics.open_sockets.monitor() - -metrics.leaked_sockets.monitor(logger) - -export async function createServer() { - const app = express() - - app.use(metrics.http.monitor(logger)) - metrics.injectMetricsRoute(app) - - // See https://github.com/exegesis-js/exegesis/blob/master/docs/Options.md - const options = { - controllers: { messagesController }, - ignoreServers: true, - allowMissingControllers: false, - } - - // const exegesisMiddleware = await exegesisExpress.middleware( - const exegesisMiddleware = await exegesisExpress.middleware( - path.resolve(__dirname, '../../chat.yaml'), - options - ) - - // If you have any body parsers, this should go before them. - app.use(exegesisMiddleware) - - // Return a 404 - app.use((req, res) => { - res.status(404).json({ message: `Not found` }) - }) - - // Handle any unexpected errors - app.use((err, req, res, next) => { - res.status(500).json({ message: `Internal error: ${err.message}` }) - }) - - const server = http.createServer(app) - return { app, server } -} diff --git a/services/chat/chat.yaml b/services/chat/chat.yaml deleted file mode 100644 index b293d1e7df..0000000000 --- a/services/chat/chat.yaml +++ /dev/null @@ -1,539 +0,0 @@ -openapi: 3.1.0 -x-stoplight: - id: okoe8mh50pjec -info: - title: chat - version: "1.0" -servers: - - url: "http://chat:3010" -x-exegesis-controller: messagesController -paths: - "/project/{projectId}/messages": - parameters: - - schema: - type: string - name: projectId - in: path - required: true - get: - summary: Get Global messages - tags: [] - responses: - "201": - description: OK - content: - application/json: - schema: - type: array - items: - $ref: "#/components/schemas/Message" - operationId: getGlobalMessages - description: Get global messages for the project with Project ID provided - parameters: - - schema: - type: string - in: query - name: before - - schema: - type: string - in: query - name: limit - post: - summary: Send Global message - operationId: sendGlobalMessage - responses: - "201": - description: OK - requestBody: - content: - application/json: - schema: - $ref: "#/components/schemas/Message" - examples: - example-1: - value: - user_id: string - content: string - description: "UserID and Content of the message to be posted. " - description: Send global message for the project with Project ID provided - "/project/{projectId}/messages/{messageId}": - parameters: - - schema: - type: string - name: projectId - in: path - required: true - - schema: - type: string - name: messageId - in: path - required: true - get: - summary: Get single global message - tags: [] - responses: - "200": - description: OK - content: - application/json: - schema: - $ref: "#/components/schemas/Message" - "404": - description: Message not found - operationId: getGlobalMessage - description: Get a single global message by message ID for the project with Project ID provided - delete: - summary: Delete global message - operationId: deleteGlobalMessage - responses: - "204": - description: No Content - description: "Delete global message" - "/project/{projectId}/thread/{threadId}/messages": - parameters: - - schema: - type: string - name: projectId - in: path - required: true - - schema: - type: string - name: threadId - in: path - required: true - post: - summary: Send message - operationId: sendMessage - responses: - "201": - description: Created - description: Add a message to the thread with thread ID provided from the Project with Project ID provided. - requestBody: - content: - application/json: - schema: - $ref: "#/components/schemas/Message" - description: |- - JSON object with : - - user_id: Id of the user - - content: Content of the message - "/project/{projectId}/threads": - parameters: - - schema: - type: string - name: projectId - in: path - required: true - get: - summary: Get Threads - tags: [] - responses: - "200": - description: OK - content: - application/json: - schema: - type: array - items: - $ref: "#/components/schemas/Thread" - examples: {} - "404": - description: Not Found - operationId: getThreads - description: Get the list of threads for the project with Project ID provided - "/project/{projectId}/thread/{threadId}/messages/{messageId}/edit": - parameters: - - schema: - type: string - name: projectId - in: path - required: true - - schema: - type: string - name: threadId - in: path - required: true - - schema: - type: string - name: messageId - in: path - required: true - post: - summary: Edit message - operationId: editMessage - responses: - "204": - description: No Content - "404": - description: Not Found - requestBody: - content: - application/json: - schema: - type: object - properties: - content: - type: string - user_id: - type: string - readOnly: true - required: - - content - examples: {} - description: |- - JSON object with : - - content: Content of the message to edit - - user_id: Id of the user (optional) - description: | - Update message with Message ID provided from the Thread ID and Project ID provided - "/project/{projectId}/messages/{messageId}/edit": - parameters: - - schema: - type: string - name: projectId - in: path - required: true - - schema: - type: string - name: messageId - in: path - required: true - post: - summary: Edit global message - operationId: editGlobalMessage - responses: - "204": - description: No Content - "404": - description: Not Found - requestBody: - content: - application/json: - schema: - type: object - properties: - content: - type: string - user_id: - type: string - readOnly: true - required: - - content - examples: {} - description: |- - JSON object with : - - content: Content of the message to edit - - user_id: Id of the user (optional) - description: | - Update global message with Message ID provided from the Project ID provided - "/project/{projectId}/thread/{threadId}/messages/{messageId}": - parameters: - - schema: - type: string - name: projectId - in: path - required: true - - schema: - type: string - name: threadId - in: path - required: true - - schema: - type: string - name: messageId - in: path - required: true - get: - summary: Get thread message - tags: [] - responses: - "200": - description: OK - content: - application/json: - schema: - $ref: "#/components/schemas/Message" - "404": - description: Message not found - operationId: getThreadMessage - description: Get a specific message by message ID from the thread with Thread ID and Project ID provided - delete: - summary: Delete message - operationId: deleteMessage - responses: - "204": - description: No Content - description: "Delete message with Message ID provided, from the Thread with ThreadID and ProjectID provided" - "/project/{projectId}/thread/{threadId}/user/{userId}/messages/{messageId}": - parameters: - - schema: - type: string - name: projectId - in: path - required: true - - schema: - type: string - name: threadId - in: path - required: true - - schema: - type: string - name: userId - in: path - required: true - - schema: - type: string - name: messageId - in: path - required: true - delete: - summary: Delete message written by a given user - operationId: deleteUserMessage - responses: - "204": - description: No Content - "/project/{projectId}/thread/{threadId}/resolve": - parameters: - - schema: - type: string - name: projectId - in: path - required: true - - schema: - type: string - name: threadId - in: path - required: true - post: - summary: Resolve Thread - operationId: resolveThread - responses: - "204": - description: No Content - requestBody: - content: - application/json: - schema: - type: object - properties: - user_id: - type: string - required: - - user_id - description: |- - JSON object with : - - user_id: Id of the user. - description: Mark Thread with ThreadID and ProjectID provided owned by the user with UserID provided as resolved. - "/project/{projectId}/thread/{threadId}/reopen": - parameters: - - schema: - type: string - name: projectId - in: path - required: true - - schema: - type: string - name: threadId - in: path - required: true - post: - summary: Reopen Thread - operationId: reopenThread - responses: - "204": - description: No Content - description: |- - Reopen Thread with ThreadID and ProjectID provided. - i.e unmark it as resolved. - "/project/{projectId}/thread/{threadId}": - parameters: - - schema: - type: string - name: projectId - in: path - required: true - - schema: - type: string - name: threadId - in: path - required: true - get: - summary: Get Thread - tags: [] - responses: - "200": - description: OK - content: - application/json: - schema: - $ref: "#/components/schemas/Thread" - "404": - description: Thread not found - operationId: getThread - description: Get a thread with ThreadID and ProjectID provided - delete: - summary: Delete thread - operationId: deleteThread - responses: - "204": - description: No Content - description: Delete thread with ThreadID and ProjectID provided - "/project/{projectId}/resolved-thread-ids": - parameters: - - schema: - type: string - name: projectId - in: path - required: true - get: - summary: Get resolved thread ids - operationId: getResolvedThreadIds - responses: - "200": - description: Resolved thread ids - "/project/{projectId}": - parameters: - - schema: - type: string - name: projectId - in: path - required: true - delete: - summary: Destroy project - operationId: destroyProject - responses: - "204": - description: No Content - description: "Delete all threads from Project with Project ID provided, and all messages in those threads." - /status: - get: - summary: Check status - tags: [] - responses: - "200": - description: OK - content: - application/json: - schema: - type: string - description: chat is alive - operationId: getStatus - description: Check that the Chat service is alive - head: - summary: Check status - tags: [] - responses: - "200": - description: OK - content: - application/json: - schema: - type: string - description: chat is alive - operationId: getStatus - description: Check that the Chat service is alive - "/project/{projectId}/duplicate-comment-threads": - parameters: - - schema: - type: string - name: projectId - in: path - required: true - post: - summary: Duplicate comment threads - operationId: duplicateCommentThreads - requestBody: - content: - application/json: - schema: - type: object - properties: - threads: - type: array - items: - type: string - responses: - "200": - content: - application/json: - schema: - type: object - properties: - newThreads: - type: object - description: Mapping of old thread ids to their duplicated thread ids - description: Duplicate a list of comment threads - "/project/{projectId}/generate-thread-data": - parameters: - - schema: - type: string - name: projectId - in: path - required: true - post: - summary: Generate thread data to load into the frontend - operationId: generateThreadData - requestBody: - content: - application/json: - schema: - type: object - properties: - threads: - type: array - items: - type: string - responses: - "200": - content: - application/json: - schema: - type: object - description: Load threads and generate a json blob containing all messages in all the threads - "/project/{projectId}/clone-comment-threads": - parameters: - - schema: - type: string - name: projectId - in: path - required: true - post: - summary: Clone a projects comment threads - operationId: cloneCommentThreads - requestBody: - content: - application/json: - schema: - type: object - properties: - targetProjectId: - type: string - responses: - "204": - description: OK - description: Clone a projects comment threads -components: - schemas: - Message: - title: Message - x-stoplight: - id: ue9n1vvezlutw - type: object - examples: - - user_id: string - - content: string - properties: - user_id: - type: string - content: - type: string - required: - - user_id - - content - Thread: - title: Thread - x-stoplight: - id: 0ppt3jw4h5bua - type: array - items: - $ref: "#/components/schemas/Message" diff --git a/services/chat/package.json b/services/chat/package.json index a3c3e6c76f..c654afcf87 100644 --- a/services/chat/package.json +++ b/services/chat/package.json @@ -21,8 +21,8 @@ "@overleaf/mongo-utils": "*", "@overleaf/promise-utils": "*", "@overleaf/settings": "*", + "@overleaf/validation-tools": "workspace:*", "async": "^3.2.5", - "exegesis-express": "4.0.0", "express": "4.22.1", "mongodb": "6.12.0" }, @@ -30,7 +30,6 @@ "@overleaf/migrations": "workspace:*", "@types/node": "^24.5.2", "acorn": "^7.1.1", - "ajv": "^6.12.0", "chai": "^4.3.6", "chai-as-promised": "^7.1.1", "eslint": "^10.4.0", diff --git a/services/chat/test/acceptance/js/CloningCommentThreadsTests.js b/services/chat/test/acceptance/js/CloningCommentThreadsTests.js index f4adde1a52..fa4c4e23f7 100644 --- a/services/chat/test/acceptance/js/CloningCommentThreadsTests.js +++ b/services/chat/test/acceptance/js/CloningCommentThreadsTests.js @@ -1,5 +1,6 @@ import { ObjectId } from '../../../app/js/mongodb.js' import { expect } from 'chai' +import { expectValidationErrorRaw } from '@overleaf/validation-tools/testUtils.js' import * as ChatClient from './helpers/ChatClient.js' import * as ChatApp from './helpers/ChatApp.js' @@ -90,4 +91,102 @@ describe('Cloning comment threads', async function () { expect(threads[this.newThreadId].messages.length).to.equal(3) }) }) + + describe('with an orphaned (non-existent) thread', async function () { + it('should report an error for that thread without failing the request', async function () { + const missingThreadId = new ObjectId().toString() + const { response, body: result } = + await ChatClient.duplicateCommentThreads(projectId, [missingThreadId]) + expect(response.statusCode).to.equal(200) + expect(result.newThreads[missingThreadId]).to.deep.equal({ + error: 'not found', + }) + }) + }) + + describe('with a malformed thread id in the body', async function () { + it('should return a graceful error', async function () { + const { response } = await ChatClient.duplicateCommentThreads(projectId, [ + 'malformed-thread-id', + ]) + expectValidationErrorRaw(response, 400, 'threads') + }) + }) + + describe('with a malformed projectId', async function () { + it('should return a not found error', async function () { + const { response } = await ChatClient.duplicateCommentThreads( + 'malformed-project', + [this.thread1Id] + ) + expectValidationErrorRaw(response, 404, 'projectId') + }) + }) +}) + +describe('Cloning comment threads to another project', async function () { + const sourceProjectId = new ObjectId().toString() + const targetProjectId = new ObjectId().toString() + const userId = new ObjectId().toString() + const threadId = new ObjectId().toString() + + before(async function () { + await ChatApp.ensureRunning() + const { response } = await ChatClient.sendMessage( + sourceProjectId, + threadId, + userId, + 'source message' + ) + expect(response.statusCode).to.equal(201) + }) + + describe('with a valid source and target project', function () { + before(async function () { + const { response } = await ChatClient.cloneCommentThreads( + sourceProjectId, + targetProjectId + ) + expect(response.statusCode).to.equal(204) + }) + + it('should copy the threads and messages into the target project', async function () { + const { response, body: threads } = + await ChatClient.getThreads(targetProjectId) + expect(response.statusCode).to.equal(200) + const threadIds = Object.keys(threads) + expect(threadIds.length).to.equal(1) + expect(threads[threadIds[0]].messages.length).to.equal(1) + expect(threads[threadIds[0]].messages[0].content).to.equal( + 'source message' + ) + }) + + it('should not remove the threads from the source project', async function () { + const { response, body: threads } = + await ChatClient.getThreads(sourceProjectId) + expect(response.statusCode).to.equal(200) + expect(threads[threadId].messages.length).to.equal(1) + }) + }) + + describe('with a malformed source projectId', function () { + it('should return a not found error', async function () { + const { response } = await ChatClient.cloneCommentThreads( + 'malformed-project', + targetProjectId + ) + expectValidationErrorRaw(response, 404, 'projectId') + }) + }) + + describe('with a malformed target projectId', function () { + it('should return a graceful error', async function () { + const { response } = await ChatClient.cloneCommentThreads( + sourceProjectId, + 'malformed-target-project' + ) + expectValidationErrorRaw(response, 400, 'targetProjectId') + }) + }) }) diff --git a/services/chat/test/acceptance/js/DeletingAMessageTests.js b/services/chat/test/acceptance/js/DeletingAMessageTests.js index 9a4a060a78..80b0c07d66 100644 --- a/services/chat/test/acceptance/js/DeletingAMessageTests.js +++ b/services/chat/test/acceptance/js/DeletingAMessageTests.js @@ -1,5 +1,6 @@ import { ObjectId } from '../../../app/js/mongodb.js' import { expect } from 'chai' +import { expectValidationErrorRaw } from '@overleaf/validation-tools/testUtils.js' import * as ChatClient from './helpers/ChatClient.js' import * as ChatApp from './helpers/ChatApp.js' @@ -44,4 +45,114 @@ describe('Deleting a message', async function () { expect(threads[threadId].messages.length).to.equal(1) }) }) + + describe('in a thread, with a malformed messageId', function () { + it('should return a not found error', async function () { + const { response } = await ChatClient.deleteMessage( + projectId, + threadId, + 'malformed-message-id' + ) + expectValidationErrorRaw(response, 404, 'messageId') + }) + }) + + describe('in a thread, with a malformed threadId', function () { + it('should return a not found error', async function () { + const { response } = await ChatClient.deleteMessage( + projectId, + 'malformed-thread-id', + new ObjectId().toString() + ) + expectValidationErrorRaw(response, 404, 'threadId') + }) + }) + + describe('globally', async function () { + let messageId + before(async function () { + const { response, body: message } = await ChatClient.sendGlobalMessage( + projectId, + userId, + 'deleted global message' + ) + expect(response.statusCode).to.equal(201) + messageId = message.id + const { response: response2 } = await ChatClient.deleteGlobalMessage( + projectId, + messageId + ) + expect(response2.statusCode).to.equal(204) + }) + + it('should then remove the message from the global messages', async function () { + const { response, body: messages } = + await ChatClient.getGlobalMessages(projectId) + expect(response.statusCode).to.equal(200) + expect(messages.map(m => m.id)).to.not.include(messageId) + }) + }) + + describe('globally, with a malformed messageId', function () { + it('should return a not found error', async function () { + const { response } = await ChatClient.deleteGlobalMessage( + projectId, + 'malformed-message-id' + ) + expectValidationErrorRaw(response, 404, 'messageId') + }) + }) + + describe('as a specific user', async function () { + let messageId + const otherUserId = new ObjectId().toString() + before(async function () { + const { response, body: message } = await ChatClient.sendMessage( + projectId, + threadId, + userId, + 'message owned by userId' + ) + expect(response.statusCode).to.equal(201) + messageId = message.id + }) + + it('should not remove the message when deleted by a different user', async function () { + const { response } = await ChatClient.deleteUserMessage( + projectId, + threadId, + otherUserId, + messageId + ) + expect(response.statusCode).to.equal(204) + const { body: threads } = await ChatClient.getThreads(projectId) + expect(threads[threadId].messages.map(m => m.id)).to.include(messageId) + }) + + it('should remove the message when deleted by its owner', async function () { + const { response } = await ChatClient.deleteUserMessage( + projectId, + threadId, + userId, + messageId + ) + expect(response.statusCode).to.equal(204) + const { body: threads } = await ChatClient.getThreads(projectId) + expect(threads[threadId].messages.map(m => m.id)).to.not.include( + messageId + ) + }) + }) + + describe('as a specific user, with a malformed userId', function () { + it('should return a not found error', async function () { + const { response } = await ChatClient.deleteUserMessage( + projectId, + threadId, + 'malformed-user', + new ObjectId().toString() + ) + expectValidationErrorRaw(response, 404, 'userId') + }) + }) }) diff --git a/services/chat/test/acceptance/js/DeletingAThreadTests.js b/services/chat/test/acceptance/js/DeletingAThreadTests.js index c199218915..213a3992e1 100644 --- a/services/chat/test/acceptance/js/DeletingAThreadTests.js +++ b/services/chat/test/acceptance/js/DeletingAThreadTests.js @@ -1,5 +1,6 @@ import { ObjectId } from '../../../app/js/mongodb.js' import { expect } from 'chai' +import { expectValidationErrorRaw } from '@overleaf/validation-tools/testUtils.js' import * as ChatClient from './helpers/ChatClient.js' import * as ChatApp from './helpers/ChatApp.js' @@ -35,4 +36,24 @@ describe('Deleting a thread', async function () { expect(Object.keys(threads).length).to.equal(0) }) }) + + describe('with a malformed threadId', function () { + it('should return a not found error', async function () { + const { response } = await ChatClient.deleteThread( + projectId, + 'malformed-thread-id' + ) + expectValidationErrorRaw(response, 404, 'threadId') + }) + }) + + describe('with a malformed projectId', function () { + it('should return a not found error', async function () { + const { response } = await ChatClient.deleteThread( + 'malformed-project', + new ObjectId().toString() + ) + expectValidationErrorRaw(response, 404, 'projectId') + }) + }) }) diff --git a/services/chat/test/acceptance/js/DestroyingAProjectTests.js b/services/chat/test/acceptance/js/DestroyingAProjectTests.js index 95c6c35033..fe3752df2e 100644 --- a/services/chat/test/acceptance/js/DestroyingAProjectTests.js +++ b/services/chat/test/acceptance/js/DestroyingAProjectTests.js @@ -1,5 +1,6 @@ import { ObjectId } from '../../../app/js/mongodb.js' import { expect } from 'chai' +import { expectValidationErrorRaw } from '@overleaf/validation-tools/testUtils.js' import * as ChatClient from './helpers/ChatClient.js' import * as ChatApp from './helpers/ChatApp.js' @@ -63,4 +64,11 @@ describe('Destroying a project', async function () { expect(globalThreadMessage).to.be.null }) }) + + describe('with a malformed projectId', function () { + it('should return a not found error', async function () { + const { response } = await ChatClient.destroyProject('malformed-project') + expectValidationErrorRaw(response, 404, 'projectId') + }) + }) }) diff --git a/services/chat/test/acceptance/js/EditingAMessageTests.js b/services/chat/test/acceptance/js/EditingAMessageTests.js index 39f7edb28c..7ea900fe6f 100644 --- a/services/chat/test/acceptance/js/EditingAMessageTests.js +++ b/services/chat/test/acceptance/js/EditingAMessageTests.js @@ -1,8 +1,10 @@ import { ObjectId } from '../../../app/js/mongodb.js' import { expect } from 'chai' +import { expectValidationErrorRaw } from '@overleaf/validation-tools/testUtils.js' import * as ChatClient from './helpers/ChatClient.js' import * as ChatApp from './helpers/ChatApp.js' +import { MAX_MESSAGE_LENGTH } from '../../../app/js/Features/Messages/MessageHttpSchemas.js' describe('Editing a message', async function () { let projectId, userId, threadId @@ -31,6 +33,71 @@ describe('Editing a message', async function () { messageId = message.id }) + describe('with a malformed messageId', function () { + it('should return a not found error', async function () { + const { response } = await ChatClient.editMessage( + projectId, + threadId, + 'malformed-message-id', + newContent + ) + expectValidationErrorRaw(response, 404, 'messageId') + }) + }) + + describe('with a malformed projectId', function () { + it('should return a not found error', async function () { + const { response } = await ChatClient.editMessage( + 'malformed-project', + threadId, + messageId, + newContent + ) + expectValidationErrorRaw(response, 404, 'projectId') + }) + }) + + describe('with a malformed threadId', function () { + it('should return a not found error', async function () { + const { response } = await ChatClient.editMessage( + projectId, + 'malformed-thread-id', + messageId, + newContent + ) + expectValidationErrorRaw(response, 404, 'threadId') + }) + }) + + describe('with no content', function () { + it('should return a graceful error', async function () { + const { response } = await ChatClient.editMessage( + projectId, + threadId, + messageId, + '' + ) + expectValidationErrorRaw(response, 400, 'No content provided') + }) + }) + + describe('with very long content', function () { + it('should return a graceful error', async function () { + const tooLongContent = '-'.repeat(MAX_MESSAGE_LENGTH + 1) + const { response } = await ChatClient.editMessage( + projectId, + threadId, + messageId, + tooLongContent + ) + expectValidationErrorRaw( + response, + 400, + `Content too long (> ${MAX_MESSAGE_LENGTH} bytes)` + ) + }) + }) + describe('without user', function () { beforeEach(async function () { const { response } = await ChatClient.editMessage( @@ -93,4 +160,100 @@ describe('Editing a message', async function () { }) }) }) + + describe('globally', async function () { + const content = 'global message' + const newContent = 'updated global message' + let messageId + beforeEach(async function () { + projectId = new ObjectId().toString() + userId = new ObjectId().toString() + + const { response, body: message } = await ChatClient.sendGlobalMessage( + projectId, + userId, + content + ) + expect(response.statusCode).to.equal(201) + expect(message.id).to.exist + messageId = message.id + }) + + describe('with a malformed messageId', function () { + it('should return a not found error', async function () { + const { response } = await ChatClient.editGlobalMessage( + projectId, + 'malformed-message-id', + newContent + ) + expectValidationErrorRaw(response, 404, 'messageId') + }) + }) + + describe('with a malformed projectId', function () { + it('should return a not found error', async function () { + const { response } = await ChatClient.editGlobalMessage( + 'malformed-project', + messageId, + newContent + ) + expectValidationErrorRaw(response, 404, 'projectId') + }) + }) + + describe('with very long content', function () { + it('should return a graceful error', async function () { + const tooLongContent = '-'.repeat(MAX_MESSAGE_LENGTH + 1) + const { response } = await ChatClient.editGlobalMessage( + projectId, + messageId, + tooLongContent + ) + expectValidationErrorRaw( + response, + 400, + `Content too long (> ${MAX_MESSAGE_LENGTH} bytes)` + ) + }) + }) + + describe('without user', function () { + beforeEach(async function () { + const { response } = await ChatClient.editGlobalMessage( + projectId, + messageId, + newContent + ) + expect(response.statusCode).to.equal(204) + }) + + it('should then list the updated message in the global messages', async function () { + const { response, body: messages } = + await ChatClient.getGlobalMessages(projectId) + expect(response.statusCode).to.equal(200) + expect(messages.length).to.equal(1) + expect(messages[0].content).to.equal(newContent) + }) + }) + + describe('with another user', function () { + beforeEach(async function () { + const { response } = await ChatClient.editGlobalMessageWithUser( + projectId, + messageId, + new ObjectId(), + newContent + ) + expect(response.statusCode).to.equal(404) + }) + + it('should then list the old message in the global messages', async function () { + const { response, body: messages } = + await ChatClient.getGlobalMessages(projectId) + expect(response.statusCode).to.equal(200) + expect(messages.length).to.equal(1) + expect(messages[0].content).to.equal(content) + }) + }) + }) }) diff --git a/services/chat/test/acceptance/js/GettingAMessageTests.js b/services/chat/test/acceptance/js/GettingAMessageTests.js new file mode 100644 index 0000000000..800e3cff60 --- /dev/null +++ b/services/chat/test/acceptance/js/GettingAMessageTests.js @@ -0,0 +1,132 @@ +import { ObjectId } from '../../../app/js/mongodb.js' +import { expect } from 'chai' +import { expectValidationErrorRaw } from '@overleaf/validation-tools/testUtils.js' + +import * as ChatClient from './helpers/ChatClient.js' +import * as ChatApp from './helpers/ChatApp.js' + +describe('Getting a message', async function () { + before(async function () { + await ChatApp.ensureRunning() + }) + + describe('globally', async function () { + const projectId = new ObjectId().toString() + const userId = new ObjectId().toString() + const content = 'global message' + let messageId + + before(async function () { + const { response, body: message } = await ChatClient.sendGlobalMessage( + projectId, + userId, + content + ) + expect(response.statusCode).to.equal(201) + messageId = message.id + }) + + it('should return the message', async function () { + const { response, body: message } = await ChatClient.getGlobalMessage( + projectId, + messageId + ) + expect(response.statusCode).to.equal(200) + expect(message.content).to.equal(content) + expect(message.user_id).to.equal(userId) + }) + + describe('when the message does not exist', function () { + it('should return 404', async function () { + const { response } = await ChatClient.getGlobalMessage( + projectId, + new ObjectId().toString() + ) + expect(response.statusCode).to.equal(404) + }) + }) + + describe('with a malformed projectId', function () { + it('should return a not found error', async function () { + const { response } = await ChatClient.getGlobalMessage( + 'malformed-project', + messageId + ) + expectValidationErrorRaw(response, 404, 'projectId') + }) + }) + + describe('with a malformed messageId', function () { + it('should return a not found error', async function () { + const { response } = await ChatClient.getGlobalMessage( + projectId, + 'malformed-message-id' + ) + expectValidationErrorRaw(response, 404, 'messageId') + }) + }) + }) + + describe('in a thread', async function () { + const projectId = new ObjectId().toString() + const threadId = new ObjectId().toString() + const userId = new ObjectId().toString() + const content = 'thread message' + let messageId + + before(async function () { + const { response, body: message } = await ChatClient.sendMessage( + projectId, + threadId, + userId, + content + ) + expect(response.statusCode).to.equal(201) + messageId = message.id + }) + + it('should return the message', async function () { + const { response, body: message } = await ChatClient.getThreadMessage( + projectId, + threadId, + messageId + ) + expect(response.statusCode).to.equal(200) + expect(message.content).to.equal(content) + expect(message.user_id).to.equal(userId) + }) + + describe('when the message does not exist', function () { + it('should return 404', async function () { + const { response } = await ChatClient.getThreadMessage( + projectId, + threadId, + new ObjectId().toString() + ) + expect(response.statusCode).to.equal(404) + }) + }) + + describe('with a malformed threadId', function () { + it('should return a not found error', async function () { + const { response } = await ChatClient.getThreadMessage( + projectId, + 'malformed-thread-id', + messageId + ) + expectValidationErrorRaw(response, 404, 'threadId') + }) + }) + + describe('with a malformed messageId', function () { + it('should return a not found error', async function () { + const { response } = await ChatClient.getThreadMessage( + projectId, + threadId, + 'malformed-message-id' + ) + expectValidationErrorRaw(response, 404, 'messageId') + }) + }) + }) +}) diff --git a/services/chat/test/acceptance/js/GettingAThreadTests.js b/services/chat/test/acceptance/js/GettingAThreadTests.js index 3d9be34b25..0d83d22d8d 100644 --- a/services/chat/test/acceptance/js/GettingAThreadTests.js +++ b/services/chat/test/acceptance/js/GettingAThreadTests.js @@ -1,5 +1,6 @@ import { ObjectId } from '../../../app/js/mongodb.js' import { expect } from 'chai' +import { expectValidationErrorRaw } from '@overleaf/validation-tools/testUtils.js' import * as ChatClient from './helpers/ChatClient.js' import * as ChatApp from './helpers/ChatApp.js' @@ -64,4 +65,24 @@ describe('Getting a thread', async function () { expect(response.statusCode).to.equal(404) }) }) + + describe('with a malformed threadId', function () { + it('should return a not found error naming the field', async function () { + const { response } = await ChatClient.getThread( + projectId, + 'malformed-thread-id' + ) + expectValidationErrorRaw(response, 404, 'threadId') + }) + }) + + describe('with a malformed projectId', function () { + it('should return a not found error naming the field', async function () { + const { response } = await ChatClient.getThread( + 'malformed-project', + threadId + ) + expectValidationErrorRaw(response, 404, 'projectId') + }) + }) }) diff --git a/services/chat/test/acceptance/js/GettingMessagesTests.js b/services/chat/test/acceptance/js/GettingMessagesTests.js index 4fb20da2a1..38dca90ef8 100644 --- a/services/chat/test/acceptance/js/GettingMessagesTests.js +++ b/services/chat/test/acceptance/js/GettingMessagesTests.js @@ -1,5 +1,6 @@ import { ObjectId } from '../../../app/js/mongodb.js' import { expect } from 'chai' +import { expectValidationErrorRaw } from '@overleaf/validation-tools/testUtils.js' import * as ChatClient from './helpers/ChatClient.js' import * as ChatApp from './helpers/ChatApp.js' @@ -8,7 +9,7 @@ async function getCount() { return await ChatClient.getMetric(line => { return ( line.includes('timer_http_request_count') && - line.includes('path="project_{projectId}_messages"') && + line.includes('path="project_projectId_messages"') && line.includes('method="POST"') ) }) @@ -161,4 +162,31 @@ describe('Getting messages', async function () { expect(thread1.messages[1].user_id).to.equal(userId1) }) }) + + describe('with a malformed thread id in the list', function () { + it('should return a graceful error', async function () { + const projectId = new ObjectId().toString() + const { response } = await ChatClient.generateThreadData(projectId, [ + 'malformed-thread-id', + ]) + expectValidationErrorRaw(response, 400, 'threads') + }) + }) + + describe('with a malformed projectId', function () { + it('should return a not found error for threads', async function () { + const { response } = await ChatClient.asyncRequest({ + method: 'get', + url: `/project/malformed-project/threads`, + json: true, + }) + expectValidationErrorRaw(response, 404, 'projectId') + }) + + it('should return a not found error for global messages', async function () { + const { response } = + await ChatClient.getGlobalMessages('malformed-project') + expectValidationErrorRaw(response, 404, 'projectId') + }) + }) }) diff --git a/services/chat/test/acceptance/js/ResolvingAThreadTests.js b/services/chat/test/acceptance/js/ResolvingAThreadTests.js index 208d2324fd..f4809195b1 100644 --- a/services/chat/test/acceptance/js/ResolvingAThreadTests.js +++ b/services/chat/test/acceptance/js/ResolvingAThreadTests.js @@ -1,5 +1,6 @@ import { ObjectId } from '../../../app/js/mongodb.js' import { expect } from 'chai' +import { expectValidationErrorRaw } from '@overleaf/validation-tools/testUtils.js' import * as ChatClient from './helpers/ChatClient.js' import * as ChatApp from './helpers/ChatApp.js' @@ -111,4 +112,43 @@ describe('Resolving a thread', async function () { expect(body.resolvedThreadIds).not.to.include(threadId) }) }) + + describe('with a malformed threadId', function () { + it('should return a not found error when resolving', async function () { + const { response } = await ChatClient.resolveThread( + projectId, + 'malformed-thread-id', + userId + ) + expectValidationErrorRaw(response, 404, 'threadId') + }) + + it('should return a not found error when reopening', async function () { + const { response } = await ChatClient.reopenThread( + projectId, + 'malformed-thread-id' + ) + expectValidationErrorRaw(response, 404, 'threadId') + }) + }) + + describe('with a malformed userId when resolving', function () { + it('should return a graceful error', async function () { + const threadId = new ObjectId().toString() + const { response } = await ChatClient.resolveThread( + projectId, + threadId, + 'malformed-user' + ) + expectValidationErrorRaw(response, 400, 'user_id') + }) + }) + + describe('with a malformed projectId', function () { + it('should return a not found error for resolved-thread-ids', async function () { + const { response } = + await ChatClient.getResolvedThreadIds('malformed-project') + expectValidationErrorRaw(response, 404, 'projectId') + }) + }) }) diff --git a/services/chat/test/acceptance/js/SendingAMessageTests.js b/services/chat/test/acceptance/js/SendingAMessageTests.js index 7dbc1df3a5..46ad17084d 100644 --- a/services/chat/test/acceptance/js/SendingAMessageTests.js +++ b/services/chat/test/acceptance/js/SendingAMessageTests.js @@ -1,8 +1,10 @@ import { ObjectId } from '../../../app/js/mongodb.js' import { expect } from 'chai' +import { expectValidationErrorRaw } from '@overleaf/validation-tools/testUtils.js' import * as ChatClient from './helpers/ChatClient.js' import * as ChatApp from './helpers/ChatApp.js' +import { MAX_MESSAGE_LENGTH } from '../../../app/js/Features/Messages/MessageHttpSchemas.js' describe('Sending a message', async function () { before(async function () { @@ -80,63 +82,113 @@ describe('Sending a message', async function () { 'malformed-user', 'content' ) - expect(response.statusCode).to.equal(400) - expect(body).to.equal('Invalid userId') + expectValidationErrorRaw(response, 400, 'user_id') + expect(body.error).to.include('Invalid Mongo ObjectId') }) }) describe('with a malformed projectId', async function () { - it('should return a graceful error', async function () { - const { response, body } = await ChatClient.sendMessage( + it('should return a not found error', async function () { + const { response } = await ChatClient.sendMessage( 'malformed-project', threadId, userId, 'content' ) - expect(response.statusCode).to.equal(400) - expect(body).to.equal('Invalid projectId') + expectValidationErrorRaw(response, 404, 'projectId') }) }) describe('with a malformed threadId', async function () { - it('should return a graceful error', async function () { - const { response, body } = await ChatClient.sendMessage( + it('should return a not found error', async function () { + const { response } = await ChatClient.sendMessage( projectId, 'malformed-thread-id', userId, 'content' ) - expect(response.statusCode).to.equal(400) - expect(body).to.equal('Invalid threadId') + expectValidationErrorRaw(response, 404, 'threadId') }) }) describe('with no content', async function () { it('should return a graceful error', async function () { - const { response, body } = await ChatClient.sendMessage( + const { response } = await ChatClient.sendMessage( projectId, threadId, userId, null ) - expect(response.statusCode).to.equal(400) - // Exegesis is responding with validation errors. I can“t find a way to choose the validation error yet. - // expect(body).to.equal('No content provided') - expect(body.message).to.equal('Validation errors') + expectValidationErrorRaw(response, 400, 'No content provided') }) }) describe('with very long content', async function () { it('should return a graceful error', async function () { - const content = '-'.repeat(10 * 1024 + 1) - const { response, body } = await ChatClient.sendMessage( + const content = '-'.repeat(MAX_MESSAGE_LENGTH + 1) + const { response } = await ChatClient.sendMessage( projectId, threadId, userId, content ) - expect(response.statusCode).to.equal(400) - expect(body).to.equal('Content too long (> 10240 bytes)') + expectValidationErrorRaw( + response, + 400, + `Content too long (> ${MAX_MESSAGE_LENGTH} bytes)` + ) + }) + }) + + describe('with an unknown field in the body', async function () { + it('should return a graceful error', async function () { + const { response } = await ChatClient.asyncRequest({ + method: 'post', + url: `/project/${projectId}/thread/${threadId}/messages`, + json: { + user_id: userId, + content: 'content', + unknownField: 'x', + }, + }) + expectValidationErrorRaw(response, 400, 'unknownField') + }) + }) + + describe('with a malformed limit', async function () { + it('should return a graceful error', async function () { + const { response } = await ChatClient.asyncRequest({ + method: 'get', + url: `/project/${projectId}/messages?limit=nope`, + json: true, + }) + expectValidationErrorRaw(response, 400, 'limit') + }) + }) + }) + + describe('globally: failure cases', async function () { + const projectId = new ObjectId().toString() + + describe('with a malformed userId', async function () { + it('should return a graceful error', async function () { + const { response } = await ChatClient.sendGlobalMessage( + projectId, + 'malformed-user', + 'content' + ) + expectValidationErrorRaw(response, 400, 'user_id') + }) + }) + + describe('with a malformed projectId', async function () { + it('should return a not found error', async function () { + const { response } = await ChatClient.sendGlobalMessage( + 'malformed-project', + new ObjectId().toString(), + 'content' + ) + expectValidationErrorRaw(response, 404, 'projectId') }) }) }) diff --git a/services/chat/test/acceptance/js/helpers/ChatApp.js b/services/chat/test/acceptance/js/helpers/ChatApp.js index c67090ae0f..8b8cf230aa 100644 --- a/services/chat/test/acceptance/js/helpers/ChatApp.js +++ b/services/chat/test/acceptance/js/helpers/ChatApp.js @@ -1,4 +1,4 @@ -import { createServer } from '../../../../app/js/server.js' +import app from '../../../../app.js' import { promisify } from 'node:util' import './MongoHelper.js' import testLogRecorder from '@overleaf/logger/test-log-recorder.js' @@ -9,7 +9,6 @@ let serverPromise = null export async function ensureRunning() { if (!serverPromise) { - const { app } = await createServer() const startServer = promisify(app.listen.bind(app)) serverPromise = startServer(3010, '127.0.0.1') } diff --git a/services/chat/test/acceptance/js/helpers/ChatClient.js b/services/chat/test/acceptance/js/helpers/ChatClient.js index 98314bb22a..7c0ecbddf9 100644 --- a/services/chat/test/acceptance/js/helpers/ChatClient.js +++ b/services/chat/test/acceptance/js/helpers/ChatClient.js @@ -4,7 +4,7 @@ const request = Request.defaults({ baseUrl: 'http://127.0.0.1:3010', }) -async function asyncRequest(options) { +export async function asyncRequest(options) { return await new Promise((resolve, reject) => { request(options, (err, response, body) => { if (err) { @@ -35,6 +35,47 @@ export async function getGlobalMessages(projectId) { }) } +export async function getGlobalMessage(projectId, messageId) { + return await asyncRequest({ + method: 'get', + url: `/project/${projectId}/messages/${messageId}`, + json: true, + }) +} + +export async function deleteGlobalMessage(projectId, messageId) { + return await asyncRequest({ + method: 'delete', + url: `/project/${projectId}/messages/${messageId}`, + }) +} + +export async function editGlobalMessage(projectId, messageId, content) { + return await asyncRequest({ + method: 'post', + url: `/project/${projectId}/messages/${messageId}/edit`, + json: { + content, + }, + }) +} + +export async function editGlobalMessageWithUser( + projectId, + messageId, + userId, + content +) { + return await asyncRequest({ + method: 'post', + url: `/project/${projectId}/messages/${messageId}/edit`, + json: { + content, + userId, + }, + }) +} + export async function sendMessage(projectId, threadId, userId, content) { return await asyncRequest({ method: 'post', @@ -54,6 +95,14 @@ export async function getThread(projectId, threadId) { }) } +export async function getThreadMessage(projectId, threadId, messageId) { + return await asyncRequest({ + method: 'get', + url: `/project/${projectId}/thread/${threadId}/messages/${messageId}`, + json: true, + }) +} + export async function getThreads(projectId) { return await asyncRequest({ method: 'get', @@ -146,6 +195,18 @@ export async function deleteMessage(projectId, threadId, messageId) { }) } +export async function deleteUserMessage( + projectId, + threadId, + userId, + messageId +) { + return await asyncRequest({ + method: 'delete', + url: `/project/${projectId}/thread/${threadId}/user/${userId}/messages/${messageId}`, + }) +} + export async function destroyProject(projectId) { return await asyncRequest({ method: 'delete', @@ -172,3 +233,13 @@ export async function generateThreadData(projectId, threads) { }, }) } + +export async function cloneCommentThreads(sourceProjectId, targetProjectId) { + return await asyncRequest({ + method: 'post', + url: `/project/${sourceProjectId}/clone-comment-threads`, + json: { + targetProjectId, + }, + }) +} diff --git a/yarn.lock b/yarn.lock index b874568540..66f7e958f1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -297,18 +297,6 @@ __metadata: languageName: node linkType: hard -"@apidevtools/json-schema-ref-parser@npm:^9.0.3": - version: 9.1.2 - resolution: "@apidevtools/json-schema-ref-parser@npm:9.1.2" - dependencies: - "@jsdevtools/ono": "npm:^7.1.3" - "@types/json-schema": "npm:^7.0.6" - call-me-maybe: "npm:^1.0.1" - js-yaml: "npm:^4.1.0" - checksum: 10c0/ebf952eb2e00bf0919f024e72897e047fd5012f0a9e47ac361873f6de0a733b9334513cdbc73205a6b43ac4a652b8c87f55e489c39b2d60bd0bc1cb2b411e218 - languageName: node - linkType: hard - "@arrows/array@npm:^1.4.1": version: 1.4.1 resolution: "@arrows/array@npm:1.4.1" @@ -4816,13 +4804,6 @@ __metadata: languageName: node linkType: hard -"@jsdevtools/ono@npm:^7.1.3": - version: 7.1.3 - resolution: "@jsdevtools/ono@npm:7.1.3" - checksum: 10c0/a9f7e3e8e3bc315a34959934a5e2f874c423cf4eae64377d3fc9de0400ed9f36cb5fd5ebce3300d2e8f4085f557c4a8b591427a583729a87841fda46e6c216b9 - languageName: node - linkType: hard - "@jsonjoy.com/base64@npm:17.67.0": version: 17.67.0 resolution: "@jsonjoy.com/base64@npm:17.67.0" @@ -6928,14 +6909,13 @@ __metadata: "@overleaf/mongo-utils": "npm:*" "@overleaf/promise-utils": "npm:*" "@overleaf/settings": "npm:*" + "@overleaf/validation-tools": "workspace:*" "@types/node": "npm:^24.5.2" acorn: "npm:^7.1.1" - ajv: "npm:^6.12.0" async: "npm:^3.2.5" chai: "npm:^4.3.6" chai-as-promised: "npm:^7.1.1" eslint: "npm:^10.4.0" - exegesis-express: "npm:4.0.0" express: "npm:4.22.1" mocha: "npm:^11.1.0" mocha-junit-reporter: "npm:^2.2.1" @@ -10971,7 +10951,7 @@ __metadata: languageName: node linkType: hard -"@types/json-schema@npm:*, @types/json-schema@npm:^7.0.15, @types/json-schema@npm:^7.0.6, @types/json-schema@npm:^7.0.8, @types/json-schema@npm:^7.0.9": +"@types/json-schema@npm:*, @types/json-schema@npm:^7.0.15, @types/json-schema@npm:^7.0.8, @types/json-schema@npm:^7.0.9": version: 7.0.15 resolution: "@types/json-schema@npm:7.0.15" checksum: 10c0/a996a745e6c5d60292f36731dd41341339d4eeed8180bb09226e5c8d23759067692b1d88e5d91d72ee83dfc00d3aca8e7bd43ea120516c17922cbcb7c3e252db @@ -12793,7 +12773,7 @@ __metadata: languageName: node linkType: hard -"ajv-formats@npm:^2.1.0, ajv-formats@npm:^2.1.1": +"ajv-formats@npm:^2.1.1": version: 2.1.1 resolution: "ajv-formats@npm:2.1.1" dependencies: @@ -12827,7 +12807,7 @@ __metadata: languageName: node linkType: hard -"ajv@npm:^6.12.0, ajv@npm:^6.12.3, ajv@npm:^6.12.5": +"ajv@npm:^6.12.3, ajv@npm:^6.12.5": version: 6.14.0 resolution: "ajv@npm:6.14.0" dependencies: @@ -12851,7 +12831,7 @@ __metadata: languageName: node linkType: hard -"ajv@npm:^8.0.0, ajv@npm:^8.0.1, ajv@npm:^8.12.0, ajv@npm:^8.3.0, ajv@npm:^8.9.0": +"ajv@npm:^8.0.0, ajv@npm:^8.0.1, ajv@npm:^8.12.0, ajv@npm:^8.9.0": version: 8.18.0 resolution: "ajv@npm:8.18.0" dependencies: @@ -14493,13 +14473,6 @@ __metadata: languageName: node linkType: hard -"call-me-maybe@npm:^1.0.1": - version: 1.0.2 - resolution: "call-me-maybe@npm:1.0.2" - checksum: 10c0/8eff5dbb61141ebb236ed71b4e9549e488bcb5451c48c11e5667d5c75b0532303788a1101e6978cafa2d0c8c1a727805599c2741e3e0982855c9f1d78cd06c9f - languageName: node - linkType: hard - "callsites@npm:^3.0.0": version: 3.1.0 resolution: "callsites@npm:3.1.0" @@ -15437,7 +15410,7 @@ __metadata: languageName: node linkType: hard -"content-type@npm:^1.0.4, content-type@npm:^1.0.5, content-type@npm:~1.0.4, content-type@npm:~1.0.5": +"content-type@npm:^1.0.5, content-type@npm:~1.0.4, content-type@npm:~1.0.5": version: 1.0.5 resolution: "content-type@npm:1.0.5" checksum: 10c0/b76ebed15c000aee4678c3707e0860cb6abd4e680a598c0a26e17f0bfae723ec9cc2802f0ff1bc6e4d80603719010431d2231018373d4dde10f9ccff9dadf5af @@ -16514,13 +16487,6 @@ __metadata: languageName: node linkType: hard -"deep-freeze@npm:0.0.1": - version: 0.0.1 - resolution: "deep-freeze@npm:0.0.1" - checksum: 10c0/b32c878395df6ca0e07d065750e14bc1651ec76e99490bca25e5844f7321676d7045d4eb4123892a0d4f08c38e4b7fa46d6e834782c095723447c0ee2ad0340b - languageName: node - linkType: hard - "deep-is@npm:^0.1.3": version: 0.1.4 resolution: "deep-is@npm:0.1.4" @@ -18258,13 +18224,6 @@ __metadata: languageName: node linkType: hard -"events-listener@npm:^1.1.0": - version: 1.1.0 - resolution: "events-listener@npm:1.1.0" - checksum: 10c0/85a2be3d27cef22edb6e8bccfd2d87c9f9f6854a834129958c7a0410284b6c9c69efff40554ba8ae3f4c1dc9b036c5a07c5d5df8b31041b3b93722c91dc3b140 - languageName: node - linkType: hard - "events-universal@npm:^1.0.0": version: 1.0.1 resolution: "events-universal@npm:1.0.1" @@ -18344,39 +18303,6 @@ __metadata: languageName: node linkType: hard -"exegesis-express@npm:4.0.0": - version: 4.0.0 - resolution: "exegesis-express@npm:4.0.0" - dependencies: - exegesis: "npm:^4.1.0" - checksum: 10c0/09e32a03f0bbafc09cc19d213fdc61584cda52f7f465b1cd4faba55a8c87eb956c00bb0a72f53770799669e95c4d6af525983c639ab920333d3b0397592e8241 - languageName: node - linkType: hard - -"exegesis@npm:^4.1.0": - version: 4.3.0 - resolution: "exegesis@npm:4.3.0" - dependencies: - "@apidevtools/json-schema-ref-parser": "npm:^9.0.3" - ajv: "npm:^8.3.0" - ajv-formats: "npm:^2.1.0" - body-parser: "npm:^1.18.3" - content-type: "npm:^1.0.4" - deep-freeze: "npm:0.0.1" - events-listener: "npm:^1.1.0" - glob: "npm:^10.3.10" - json-ptr: "npm:^3.0.1" - json-schema-traverse: "npm:^1.0.0" - lodash: "npm:^4.17.11" - openapi3-ts: "npm:^3.1.1" - promise-breaker: "npm:^6.0.0" - qs: "npm:^6.6.0" - raw-body: "npm:^2.3.3" - semver: "npm:^7.0.0" - checksum: 10c0/944ec9df00378ef19eb37a52aff60244e3bb98878e5979d7efb98b8454b144d9bb7ec08ea8d23e54430d97e2d0bf9e59341f85a471e1288a607cf13ceabfdbe1 - languageName: node - linkType: hard - "exifr@npm:^7.0.0": version: 7.1.3 resolution: "exifr@npm:7.1.3" @@ -19681,7 +19607,7 @@ __metadata: languageName: node linkType: hard -"glob@npm:^10.3.10, glob@npm:^10.4.2, glob@npm:^10.4.5": +"glob@npm:^10.4.2, glob@npm:^10.4.5": version: 10.5.0 resolution: "glob@npm:10.5.0" dependencies: @@ -22289,13 +22215,6 @@ __metadata: languageName: node linkType: hard -"json-ptr@npm:^3.0.1": - version: 3.1.1 - resolution: "json-ptr@npm:3.1.1" - checksum: 10c0/f5fd7eb60a8ad52e3531eea8285d22a67bf67a16249da032cd42fabcac386135adbe8c64a5588f5b755e77b9e646ca5cc97782fe09c5cef0b6d24ebdd1e1c277 - languageName: node - linkType: hard - "json-schema-traverse@npm:^0.4.1": version: 0.4.1 resolution: "json-schema-traverse@npm:0.4.1" @@ -25776,15 +25695,6 @@ __metadata: languageName: node linkType: hard -"openapi3-ts@npm:^3.1.1": - version: 3.2.0 - resolution: "openapi3-ts@npm:3.2.0" - dependencies: - yaml: "npm:^2.2.1" - checksum: 10c0/3b9a663bf71f9292880c970a80f6f1a8db0ee475451c03b4fd336da957a24372349594d7868ce0a60b3a0875844a1f0e906e8fec8ef4220c06aa70670bfa3148 - languageName: node - linkType: hard - "optionator@npm:^0.9.3": version: 0.9.4 resolution: "optionator@npm:0.9.4" @@ -28070,13 +27980,6 @@ __metadata: languageName: node linkType: hard -"promise-breaker@npm:^6.0.0": - version: 6.0.0 - resolution: "promise-breaker@npm:6.0.0" - checksum: 10c0/fcd00836dd96d34d3376964bc3a9482251248cef00f0dcf1f2485e7d9af44f9c48b0431cf4dc780fd5a7137b8e0861e3c6557667811522e93978f6bffab66cfe - languageName: node - linkType: hard - "promise.prototype.finally@npm:^3.1.2": version: 3.1.8 resolution: "promise.prototype.finally@npm:3.1.8" @@ -28543,7 +28446,7 @@ __metadata: languageName: node linkType: hard -"raw-body@npm:^2.3.0, raw-body@npm:^2.3.3, raw-body@npm:~2.5.3": +"raw-body@npm:^2.3.0, raw-body@npm:~2.5.3": version: 2.5.3 resolution: "raw-body@npm:2.5.3" dependencies: @@ -34810,7 +34713,7 @@ __metadata: languageName: node linkType: hard -"yaml@npm:^2.0.0, yaml@npm:^2.2.1, yaml@npm:^2.3.4": +"yaml@npm:^2.0.0, yaml@npm:^2.3.4": version: 2.8.3 resolution: "yaml@npm:2.8.3" bin: