| | const mongoose = require('mongoose'); |
| | const { v4: uuidv4 } = require('uuid'); |
| | const { messageSchema } = require('@librechat/data-schemas'); |
| | const { MongoMemoryServer } = require('mongodb-memory-server'); |
| |
|
| | const { |
| | saveMessage, |
| | getMessages, |
| | updateMessage, |
| | deleteMessages, |
| | bulkSaveMessages, |
| | updateMessageText, |
| | deleteMessagesSince, |
| | } = require('./Message'); |
| |
|
| | jest.mock('~/server/services/Config/app'); |
| |
|
| | |
| | |
| | |
| | let Message; |
| |
|
| | describe('Message Operations', () => { |
| | let mongoServer; |
| | let mockReq; |
| | let mockMessageData; |
| |
|
| | beforeAll(async () => { |
| | mongoServer = await MongoMemoryServer.create(); |
| | const mongoUri = mongoServer.getUri(); |
| | Message = mongoose.models.Message || mongoose.model('Message', messageSchema); |
| | await mongoose.connect(mongoUri); |
| | }); |
| |
|
| | afterAll(async () => { |
| | await mongoose.disconnect(); |
| | await mongoServer.stop(); |
| | }); |
| |
|
| | beforeEach(async () => { |
| | |
| | await Message.deleteMany({}); |
| |
|
| | mockReq = { |
| | user: { id: 'user123' }, |
| | config: { |
| | interfaceConfig: { |
| | temporaryChatRetention: 24, |
| | }, |
| | }, |
| | }; |
| |
|
| | mockMessageData = { |
| | messageId: 'msg123', |
| | conversationId: uuidv4(), |
| | text: 'Hello, world!', |
| | user: 'user123', |
| | }; |
| | }); |
| |
|
| | describe('saveMessage', () => { |
| | it('should save a message for an authenticated user', async () => { |
| | const result = await saveMessage(mockReq, mockMessageData); |
| |
|
| | expect(result.messageId).toBe('msg123'); |
| | expect(result.user).toBe('user123'); |
| | expect(result.text).toBe('Hello, world!'); |
| |
|
| | |
| | const savedMessage = await Message.findOne({ messageId: 'msg123', user: 'user123' }); |
| | expect(savedMessage).toBeTruthy(); |
| | expect(savedMessage.text).toBe('Hello, world!'); |
| | }); |
| |
|
| | it('should throw an error for unauthenticated user', async () => { |
| | mockReq.user = null; |
| | await expect(saveMessage(mockReq, mockMessageData)).rejects.toThrow('User not authenticated'); |
| | }); |
| |
|
| | it('should handle invalid conversation ID gracefully', async () => { |
| | mockMessageData.conversationId = 'invalid-id'; |
| | const result = await saveMessage(mockReq, mockMessageData); |
| | expect(result).toBeUndefined(); |
| | }); |
| | }); |
| |
|
| | describe('updateMessageText', () => { |
| | it('should update message text for the authenticated user', async () => { |
| | |
| | await saveMessage(mockReq, mockMessageData); |
| |
|
| | |
| | await updateMessageText(mockReq, { messageId: 'msg123', text: 'Updated text' }); |
| |
|
| | |
| | const updatedMessage = await Message.findOne({ messageId: 'msg123', user: 'user123' }); |
| | expect(updatedMessage.text).toBe('Updated text'); |
| | }); |
| | }); |
| |
|
| | describe('updateMessage', () => { |
| | it('should update a message for the authenticated user', async () => { |
| | |
| | await saveMessage(mockReq, mockMessageData); |
| |
|
| | const result = await updateMessage(mockReq, { messageId: 'msg123', text: 'Updated text' }); |
| |
|
| | expect(result.messageId).toBe('msg123'); |
| | expect(result.text).toBe('Updated text'); |
| |
|
| | |
| | const updatedMessage = await Message.findOne({ messageId: 'msg123', user: 'user123' }); |
| | expect(updatedMessage.text).toBe('Updated text'); |
| | }); |
| |
|
| | it('should throw an error if message is not found', async () => { |
| | await expect( |
| | updateMessage(mockReq, { messageId: 'nonexistent', text: 'Test' }), |
| | ).rejects.toThrow('Message not found or user not authorized.'); |
| | }); |
| | }); |
| |
|
| | describe('deleteMessagesSince', () => { |
| | it('should delete messages only for the authenticated user', async () => { |
| | const conversationId = uuidv4(); |
| |
|
| | |
| | await saveMessage(mockReq, { |
| | messageId: 'msg1', |
| | conversationId, |
| | text: 'First message', |
| | user: 'user123', |
| | }); |
| |
|
| | await saveMessage(mockReq, { |
| | messageId: 'msg2', |
| | conversationId, |
| | text: 'Second message', |
| | user: 'user123', |
| | }); |
| |
|
| | await saveMessage(mockReq, { |
| | messageId: 'msg3', |
| | conversationId, |
| | text: 'Third message', |
| | user: 'user123', |
| | }); |
| |
|
| | |
| | await deleteMessagesSince(mockReq, { |
| | messageId: 'msg2', |
| | conversationId, |
| | }); |
| |
|
| | |
| | const remainingMessages = await Message.find({ conversationId, user: 'user123' }); |
| | expect(remainingMessages).toHaveLength(2); |
| | expect(remainingMessages.map((m) => m.messageId)).toContain('msg1'); |
| | expect(remainingMessages.map((m) => m.messageId)).toContain('msg2'); |
| | expect(remainingMessages.map((m) => m.messageId)).not.toContain('msg3'); |
| | }); |
| |
|
| | it('should return undefined if no message is found', async () => { |
| | const result = await deleteMessagesSince(mockReq, { |
| | messageId: 'nonexistent', |
| | conversationId: 'convo123', |
| | }); |
| | expect(result).toBeUndefined(); |
| | }); |
| | }); |
| |
|
| | describe('getMessages', () => { |
| | it('should retrieve messages with the correct filter', async () => { |
| | const conversationId = uuidv4(); |
| |
|
| | |
| | await saveMessage(mockReq, { |
| | messageId: 'msg1', |
| | conversationId, |
| | text: 'First message', |
| | user: 'user123', |
| | }); |
| |
|
| | await saveMessage(mockReq, { |
| | messageId: 'msg2', |
| | conversationId, |
| | text: 'Second message', |
| | user: 'user123', |
| | }); |
| |
|
| | const messages = await getMessages({ conversationId }); |
| | expect(messages).toHaveLength(2); |
| | expect(messages[0].text).toBe('First message'); |
| | expect(messages[1].text).toBe('Second message'); |
| | }); |
| | }); |
| |
|
| | describe('deleteMessages', () => { |
| | it('should delete messages with the correct filter', async () => { |
| | |
| | await saveMessage(mockReq, mockMessageData); |
| | await saveMessage( |
| | { user: { id: 'user456' } }, |
| | { |
| | messageId: 'msg456', |
| | conversationId: uuidv4(), |
| | text: 'Other user message', |
| | user: 'user456', |
| | }, |
| | ); |
| |
|
| | await deleteMessages({ user: 'user123' }); |
| |
|
| | |
| | const user123Messages = await Message.find({ user: 'user123' }); |
| | const user456Messages = await Message.find({ user: 'user456' }); |
| |
|
| | expect(user123Messages).toHaveLength(0); |
| | expect(user456Messages).toHaveLength(1); |
| | }); |
| | }); |
| |
|
| | describe('Conversation Hijacking Prevention', () => { |
| | it("should not allow editing a message in another user's conversation", async () => { |
| | const attackerReq = { user: { id: 'attacker123' } }; |
| | const victimConversationId = uuidv4(); |
| | const victimMessageId = 'victim-msg-123'; |
| |
|
| | |
| | const victimReq = { user: { id: 'victim123' } }; |
| | await saveMessage(victimReq, { |
| | messageId: victimMessageId, |
| | conversationId: victimConversationId, |
| | text: 'Victim message', |
| | user: 'victim123', |
| | }); |
| |
|
| | |
| | await expect( |
| | updateMessage(attackerReq, { |
| | messageId: victimMessageId, |
| | conversationId: victimConversationId, |
| | text: 'Hacked message', |
| | }), |
| | ).rejects.toThrow('Message not found or user not authorized.'); |
| |
|
| | |
| | const originalMessage = await Message.findOne({ |
| | messageId: victimMessageId, |
| | user: 'victim123', |
| | }); |
| | expect(originalMessage.text).toBe('Victim message'); |
| | }); |
| |
|
| | it("should not allow deleting messages from another user's conversation", async () => { |
| | const attackerReq = { user: { id: 'attacker123' } }; |
| | const victimConversationId = uuidv4(); |
| | const victimMessageId = 'victim-msg-123'; |
| |
|
| | |
| | const victimReq = { user: { id: 'victim123' } }; |
| | await saveMessage(victimReq, { |
| | messageId: victimMessageId, |
| | conversationId: victimConversationId, |
| | text: 'Victim message', |
| | user: 'victim123', |
| | }); |
| |
|
| | |
| | const result = await deleteMessagesSince(attackerReq, { |
| | messageId: victimMessageId, |
| | conversationId: victimConversationId, |
| | }); |
| |
|
| | expect(result).toBeUndefined(); |
| |
|
| | |
| | const victimMessage = await Message.findOne({ |
| | messageId: victimMessageId, |
| | user: 'victim123', |
| | }); |
| | expect(victimMessage).toBeTruthy(); |
| | expect(victimMessage.text).toBe('Victim message'); |
| | }); |
| |
|
| | it("should not allow inserting a new message into another user's conversation", async () => { |
| | const attackerReq = { user: { id: 'attacker123' } }; |
| | const victimConversationId = uuidv4(); |
| |
|
| | |
| | const result = await saveMessage(attackerReq, { |
| | conversationId: victimConversationId, |
| | text: 'Inserted malicious message', |
| | messageId: 'new-msg-123', |
| | user: 'attacker123', |
| | }); |
| |
|
| | expect(result).toBeTruthy(); |
| | expect(result.user).toBe('attacker123'); |
| |
|
| | |
| | const savedMessage = await Message.findOne({ messageId: 'new-msg-123' }); |
| | expect(savedMessage.user).toBe('attacker123'); |
| | expect(savedMessage.conversationId).toBe(victimConversationId); |
| | }); |
| |
|
| | it('should allow retrieving messages from any conversation', async () => { |
| | const victimConversationId = uuidv4(); |
| |
|
| | |
| | const victimReq = { user: { id: 'victim123' } }; |
| | await saveMessage(victimReq, { |
| | messageId: 'victim-msg', |
| | conversationId: victimConversationId, |
| | text: 'Victim message', |
| | user: 'victim123', |
| | }); |
| |
|
| | |
| | const messages = await getMessages({ conversationId: victimConversationId }); |
| | expect(messages).toHaveLength(1); |
| | expect(messages[0].text).toBe('Victim message'); |
| | }); |
| | }); |
| |
|
| | describe('isTemporary message handling', () => { |
| | beforeEach(() => { |
| | |
| | jest.clearAllMocks(); |
| | }); |
| |
|
| | it('should save a message with expiredAt when isTemporary is true', async () => { |
| | |
| | mockReq.config.interfaceConfig.temporaryChatRetention = 24; |
| |
|
| | mockReq.body = { isTemporary: true }; |
| |
|
| | const beforeSave = new Date(); |
| | const result = await saveMessage(mockReq, mockMessageData); |
| | const afterSave = new Date(); |
| |
|
| | expect(result.messageId).toBe('msg123'); |
| | expect(result.expiredAt).toBeDefined(); |
| | expect(result.expiredAt).toBeInstanceOf(Date); |
| |
|
| | |
| | const expectedExpirationTime = new Date(beforeSave.getTime() + 24 * 60 * 60 * 1000); |
| | const actualExpirationTime = new Date(result.expiredAt); |
| |
|
| | expect(actualExpirationTime.getTime()).toBeGreaterThanOrEqual( |
| | expectedExpirationTime.getTime() - 1000, |
| | ); |
| | expect(actualExpirationTime.getTime()).toBeLessThanOrEqual( |
| | new Date(afterSave.getTime() + 24 * 60 * 60 * 1000 + 1000).getTime(), |
| | ); |
| | }); |
| |
|
| | it('should save a message without expiredAt when isTemporary is false', async () => { |
| | mockReq.body = { isTemporary: false }; |
| |
|
| | const result = await saveMessage(mockReq, mockMessageData); |
| |
|
| | expect(result.messageId).toBe('msg123'); |
| | expect(result.expiredAt).toBeNull(); |
| | }); |
| |
|
| | it('should save a message without expiredAt when isTemporary is not provided', async () => { |
| | |
| | mockReq.body = {}; |
| |
|
| | const result = await saveMessage(mockReq, mockMessageData); |
| |
|
| | expect(result.messageId).toBe('msg123'); |
| | expect(result.expiredAt).toBeNull(); |
| | }); |
| |
|
| | it('should use custom retention period from config', async () => { |
| | |
| | mockReq.config.interfaceConfig.temporaryChatRetention = 48; |
| |
|
| | mockReq.body = { isTemporary: true }; |
| |
|
| | const beforeSave = new Date(); |
| | const result = await saveMessage(mockReq, mockMessageData); |
| |
|
| | expect(result.expiredAt).toBeDefined(); |
| |
|
| | |
| | const expectedExpirationTime = new Date(beforeSave.getTime() + 48 * 60 * 60 * 1000); |
| | const actualExpirationTime = new Date(result.expiredAt); |
| |
|
| | expect(actualExpirationTime.getTime()).toBeGreaterThanOrEqual( |
| | expectedExpirationTime.getTime() - 1000, |
| | ); |
| | expect(actualExpirationTime.getTime()).toBeLessThanOrEqual( |
| | expectedExpirationTime.getTime() + 1000, |
| | ); |
| | }); |
| |
|
| | it('should handle minimum retention period (1 hour)', async () => { |
| | |
| | mockReq.config.interfaceConfig.temporaryChatRetention = 0.5; |
| |
|
| | mockReq.body = { isTemporary: true }; |
| |
|
| | const beforeSave = new Date(); |
| | const result = await saveMessage(mockReq, mockMessageData); |
| |
|
| | expect(result.expiredAt).toBeDefined(); |
| |
|
| | |
| | const expectedExpirationTime = new Date(beforeSave.getTime() + 1 * 60 * 60 * 1000); |
| | const actualExpirationTime = new Date(result.expiredAt); |
| |
|
| | expect(actualExpirationTime.getTime()).toBeGreaterThanOrEqual( |
| | expectedExpirationTime.getTime() - 1000, |
| | ); |
| | expect(actualExpirationTime.getTime()).toBeLessThanOrEqual( |
| | expectedExpirationTime.getTime() + 1000, |
| | ); |
| | }); |
| |
|
| | it('should handle maximum retention period (8760 hours)', async () => { |
| | |
| | mockReq.config.interfaceConfig.temporaryChatRetention = 10000; |
| |
|
| | mockReq.body = { isTemporary: true }; |
| |
|
| | const beforeSave = new Date(); |
| | const result = await saveMessage(mockReq, mockMessageData); |
| |
|
| | expect(result.expiredAt).toBeDefined(); |
| |
|
| | |
| | const expectedExpirationTime = new Date(beforeSave.getTime() + 8760 * 60 * 60 * 1000); |
| | const actualExpirationTime = new Date(result.expiredAt); |
| |
|
| | expect(actualExpirationTime.getTime()).toBeGreaterThanOrEqual( |
| | expectedExpirationTime.getTime() - 1000, |
| | ); |
| | expect(actualExpirationTime.getTime()).toBeLessThanOrEqual( |
| | expectedExpirationTime.getTime() + 1000, |
| | ); |
| | }); |
| |
|
| | it('should handle missing config gracefully', async () => { |
| | |
| | delete mockReq.config; |
| |
|
| | mockReq.body = { isTemporary: true }; |
| |
|
| | const beforeSave = new Date(); |
| | const result = await saveMessage(mockReq, mockMessageData); |
| | const afterSave = new Date(); |
| |
|
| | |
| | expect(result.messageId).toBe('msg123'); |
| | expect(result.expiredAt).toBeDefined(); |
| | expect(result.expiredAt).toBeInstanceOf(Date); |
| |
|
| | |
| | const expectedExpirationTime = new Date(beforeSave.getTime() + 720 * 60 * 60 * 1000); |
| | const actualExpirationTime = new Date(result.expiredAt); |
| |
|
| | expect(actualExpirationTime.getTime()).toBeGreaterThanOrEqual( |
| | expectedExpirationTime.getTime() - 1000, |
| | ); |
| | expect(actualExpirationTime.getTime()).toBeLessThanOrEqual( |
| | new Date(afterSave.getTime() + 720 * 60 * 60 * 1000 + 1000).getTime(), |
| | ); |
| | }); |
| |
|
| | it('should use default retention when config is not provided', async () => { |
| | |
| | mockReq.config = {}; |
| |
|
| | mockReq.body = { isTemporary: true }; |
| |
|
| | const beforeSave = new Date(); |
| | const result = await saveMessage(mockReq, mockMessageData); |
| |
|
| | expect(result.expiredAt).toBeDefined(); |
| |
|
| | |
| | const expectedExpirationTime = new Date(beforeSave.getTime() + 30 * 24 * 60 * 60 * 1000); |
| | const actualExpirationTime = new Date(result.expiredAt); |
| |
|
| | expect(actualExpirationTime.getTime()).toBeGreaterThanOrEqual( |
| | expectedExpirationTime.getTime() - 1000, |
| | ); |
| | expect(actualExpirationTime.getTime()).toBeLessThanOrEqual( |
| | expectedExpirationTime.getTime() + 1000, |
| | ); |
| | }); |
| |
|
| | it('should not update expiredAt on message update', async () => { |
| | |
| | mockReq.config.interfaceConfig.temporaryChatRetention = 24; |
| |
|
| | mockReq.body = { isTemporary: true }; |
| | const savedMessage = await saveMessage(mockReq, mockMessageData); |
| | const originalExpiredAt = savedMessage.expiredAt; |
| |
|
| | |
| | mockReq.body = {}; |
| | const updatedMessage = await updateMessage(mockReq, { |
| | messageId: 'msg123', |
| | text: 'Updated text', |
| | }); |
| |
|
| | |
| | expect(updatedMessage.expiredAt).toBeUndefined(); |
| |
|
| | |
| | const dbMessage = await Message.findOne({ messageId: 'msg123', user: 'user123' }); |
| | expect(dbMessage.expiredAt).toEqual(originalExpiredAt); |
| | }); |
| |
|
| | it('should preserve expiredAt when saving existing temporary message', async () => { |
| | |
| | mockReq.config.interfaceConfig.temporaryChatRetention = 24; |
| |
|
| | mockReq.body = { isTemporary: true }; |
| | const firstSave = await saveMessage(mockReq, mockMessageData); |
| | const originalExpiredAt = firstSave.expiredAt; |
| |
|
| | |
| | await new Promise((resolve) => setTimeout(resolve, 100)); |
| |
|
| | |
| | const updatedData = { ...mockMessageData, text: 'Updated text' }; |
| | const secondSave = await saveMessage(mockReq, updatedData); |
| |
|
| | |
| | expect(secondSave.text).toBe('Updated text'); |
| | expect(secondSave.expiredAt).toBeDefined(); |
| | expect(new Date(secondSave.expiredAt).getTime()).toBeGreaterThan( |
| | new Date(originalExpiredAt).getTime(), |
| | ); |
| | }); |
| |
|
| | it('should handle bulk operations with temporary messages', async () => { |
| | |
| | const messages = [ |
| | { |
| | messageId: 'bulk1', |
| | conversationId: uuidv4(), |
| | text: 'Bulk message 1', |
| | user: 'user123', |
| | expiredAt: new Date(Date.now() + 24 * 60 * 60 * 1000), |
| | }, |
| | { |
| | messageId: 'bulk2', |
| | conversationId: uuidv4(), |
| | text: 'Bulk message 2', |
| | user: 'user123', |
| | expiredAt: null, |
| | }, |
| | ]; |
| |
|
| | await bulkSaveMessages(messages); |
| |
|
| | const savedMessages = await Message.find({ |
| | messageId: { $in: ['bulk1', 'bulk2'] }, |
| | }).lean(); |
| |
|
| | expect(savedMessages).toHaveLength(2); |
| |
|
| | const bulk1 = savedMessages.find((m) => m.messageId === 'bulk1'); |
| | const bulk2 = savedMessages.find((m) => m.messageId === 'bulk2'); |
| |
|
| | expect(bulk1.expiredAt).toBeDefined(); |
| | expect(bulk2.expiredAt).toBeNull(); |
| | }); |
| | }); |
| | }); |
| |
|