| 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(); |
| }); |
| }); |
| }); |
|
|