LibreChat/api/server/routes/__tests__/share.spec.js
Danny Avila 4b871a11ad
🧼 fix: Prevent Shared Link Caching and Strengthen Log Redaction (#13561)
* fix: tighten share caching and log redaction

* fix: sort changed imports

* fix: redact splat log arguments

* fix: avoid mutating log metadata during redaction

* fix: redact error and api_key log values

* fix: preserve error log context during redaction

* fix: cover remaining log redaction paths

* fix: bound log redaction work

* fix: align redaction scan cap with log config
2026-06-06 18:40:57 -04:00

329 lines
12 KiB
JavaScript

const express = require('express');
const request = require('supertest');
const mongoose = require('mongoose');
const mockGetSharedLinkExpiration = jest.fn();
const mockGrantCreationPermissions = jest.fn();
const mockUpdateSharedLinkPermissionsExpiration = jest.fn();
const mockSharedLinksAccess = jest.fn((_req, _res, next) => next());
jest.mock('@librechat/api', () => ({
isEnabled: jest.fn(() => true),
generateCheckAccess: jest.fn(() => mockSharedLinksAccess),
grantCreationPermissions: (...args) => mockGrantCreationPermissions(...args),
updateSharedLinkPermissionsExpiration: (...args) =>
mockUpdateSharedLinkPermissionsExpiration(...args),
ensureLinkPermissions: jest.fn(),
deleteSharedLinkWithCleanup: jest.fn(),
getSharedLinkExpiration: (...args) => mockGetSharedLinkExpiration(...args),
isActiveExpirationDate: jest.fn((expiredAt) => expiredAt > new Date()),
}));
jest.mock('@librechat/data-schemas', () => ({
logger: { error: jest.fn() },
createTempChatExpirationDate: jest.fn(() => new Date('2030-01-01T00:00:00.000Z')),
}));
jest.mock('librechat-data-provider', () => ({
PermissionTypes: {
SHARED_LINKS: 'SHARED_LINKS',
},
Permissions: {
CREATE: 'CREATE',
SHARE_PUBLIC: 'SHARE_PUBLIC',
},
RetentionMode: {
ALL: 'all',
TEMPORARY: 'temporary',
},
}));
jest.mock('mongoose', () => ({
models: {
Conversation: {
findOne: jest.fn(),
},
SharedLink: {
findOne: jest.fn(),
},
},
}));
jest.mock('~/models', () => ({
getSharedMessages: jest.fn(),
createSharedLink: jest.fn(),
updateSharedLink: jest.fn(),
deleteSharedLink: jest.fn(),
getSharedLinks: jest.fn(),
getSharedLink: jest.fn(),
getRoleByName: jest.fn(),
}));
jest.mock('~/server/middleware/canAccessSharedLink', () => (_req, _res, next) => next());
jest.mock('~/server/middleware/optionalJwtAuth', () => (req, _res, next) => next());
jest.mock('~/server/middleware/requireJwtAuth', () => (req, res, next) => next());
const { RetentionMode } = require('librechat-data-provider');
const { createTempChatExpirationDate, logger } = require('@librechat/data-schemas');
const { deleteSharedLinkWithCleanup } = require('@librechat/api');
const {
getSharedMessages,
createSharedLink,
updateSharedLink,
getRoleByName,
} = require('~/models');
const shareRouter = require('../share');
const activeExpiration = new Date('2030-01-01T00:00:00.000Z');
const expiredExpiration = new Date('2020-01-01T00:00:00.000Z');
const lean = (value) => ({
lean: jest.fn().mockResolvedValue(value),
});
const buildApp = ({ retentionMode = RetentionMode.TEMPORARY } = {}) => {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
req.user = { id: 'user-123' };
req.config = { interfaceConfig: { retentionMode } };
next();
});
app.use('/api/share', shareRouter);
return app;
};
describe('share routes retention', () => {
beforeEach(() => {
jest.clearAllMocks();
getRoleByName.mockResolvedValue({
permissions: {
SHARED_LINKS: {
SHARE_PUBLIC: true,
},
},
});
mockGrantCreationPermissions.mockResolvedValue(undefined);
});
it('prevents successful shared message responses from being cached', async () => {
getSharedMessages.mockResolvedValue({ shareId: 'share-123', messages: [] });
const response = await request(buildApp()).get('/api/share/share-123');
expect(response.status).toBe(200);
expect(response.headers['cache-control']).toBe('private, no-store');
});
it('expires new shares for retained non-temporary conversations', async () => {
mockGetSharedLinkExpiration.mockResolvedValue(activeExpiration);
createSharedLink.mockResolvedValue({ _id: 'link-123', shareId: 'share-123' });
const response = await request(buildApp())
.post('/api/share/convo-123')
.send({ targetMessageId: 'msg-123' });
expect(response.status).toBe(200);
expect(mockGetSharedLinkExpiration).toHaveBeenCalledWith(
expect.objectContaining({
conversationId: 'convo-123',
req: expect.objectContaining({ user: { id: 'user-123' } }),
}),
expect.objectContaining({
getConvo: expect.any(Function),
createExpirationDate: createTempChatExpirationDate,
logger,
}),
);
const [, dependencies] = mockGetSharedLinkExpiration.mock.calls[0];
mongoose.models.Conversation.findOne.mockReturnValue(lean({ expiredAt: activeExpiration }));
await dependencies.getConvo('user-123', 'convo-123');
expect(mongoose.models.Conversation.findOne).toHaveBeenCalledWith(
{ conversationId: 'convo-123', user: 'user-123' },
'isTemporary expiredAt',
);
expect(createSharedLink).toHaveBeenCalledWith(
'user-123',
'convo-123',
'msg-123',
new Date('2030-01-01T00:00:00.000Z'),
);
expect(mockGrantCreationPermissions).toHaveBeenCalledWith(
'link-123',
'user-123',
true,
new Date('2030-01-01T00:00:00.000Z'),
);
expect(mockSharedLinksAccess).toHaveBeenCalled();
});
it('rejects new shares when the retained conversation expired', async () => {
mockGetSharedLinkExpiration.mockResolvedValue(expiredExpiration);
createSharedLink.mockResolvedValue({ _id: 'link-123', shareId: 'share-123' });
const response = await request(buildApp())
.post('/api/share/convo-123')
.send({ targetMessageId: 'msg-123' });
expect(response.status).toBe(404);
expect(createSharedLink).not.toHaveBeenCalled();
});
it('rejects new shares for expired conversations in all retention mode', async () => {
mockGetSharedLinkExpiration.mockResolvedValue(expiredExpiration);
createSharedLink.mockResolvedValue({ _id: 'link-123', shareId: 'share-123' });
const response = await request(buildApp({ retentionMode: RetentionMode.ALL }))
.post('/api/share/convo-123')
.send({ targetMessageId: 'msg-123' });
expect(response.status).toBe(404);
expect(createSharedLink).not.toHaveBeenCalled();
});
it('expires updated shares for retained non-temporary conversations', async () => {
mongoose.models.SharedLink.findOne.mockReturnValue(lean({ conversationId: 'convo-123' }));
mockGetSharedLinkExpiration.mockResolvedValue(activeExpiration);
updateSharedLink.mockResolvedValue({ _id: 'link-456', shareId: 'share-456' });
const response = await request(buildApp()).patch('/api/share/share-123');
expect(response.status).toBe(200);
expect(mongoose.models.SharedLink.findOne).toHaveBeenCalledWith(
{ shareId: 'share-123', user: 'user-123' },
'conversationId',
);
expect(mockGetSharedLinkExpiration).toHaveBeenCalledTimes(1);
expect(mockGetSharedLinkExpiration).toHaveBeenCalledWith(
expect.objectContaining({
conversationId: 'convo-123',
req: expect.objectContaining({ user: { id: 'user-123' } }),
}),
expect.objectContaining({
getConvo: expect.any(Function),
createExpirationDate: createTempChatExpirationDate,
logger,
}),
);
expect(updateSharedLink).toHaveBeenCalledWith(
'user-123',
'share-123',
undefined,
new Date('2030-01-01T00:00:00.000Z'),
);
expect(mockUpdateSharedLinkPermissionsExpiration).toHaveBeenCalledWith(
'link-456',
new Date('2030-01-01T00:00:00.000Z'),
);
});
it('rejects updated shares when the retained conversation expired', async () => {
mongoose.models.SharedLink.findOne.mockReturnValue(lean({ conversationId: 'convo-123' }));
mockGetSharedLinkExpiration.mockResolvedValue(expiredExpiration);
updateSharedLink.mockResolvedValue({ shareId: 'share-456' });
const response = await request(buildApp()).patch('/api/share/share-123');
expect(response.status).toBe(404);
expect(updateSharedLink).not.toHaveBeenCalled();
});
it('rejects updated shares for expired conversations in all retention mode', async () => {
mongoose.models.SharedLink.findOne.mockReturnValue(lean({ conversationId: 'convo-123' }));
mockGetSharedLinkExpiration.mockResolvedValue(expiredExpiration);
updateSharedLink.mockResolvedValue({ shareId: 'share-456' });
const response = await request(buildApp({ retentionMode: RetentionMode.ALL })).patch(
'/api/share/share-123',
);
expect(response.status).toBe(404);
expect(mongoose.models.SharedLink.findOne).toHaveBeenCalledWith(
{ shareId: 'share-123', user: 'user-123' },
'conversationId',
);
expect(updateSharedLink).not.toHaveBeenCalled();
});
it('clears updated share expiration when the conversation is no longer retained', async () => {
mongoose.models.SharedLink.findOne.mockReturnValue(lean({ conversationId: 'convo-123' }));
mockGetSharedLinkExpiration.mockResolvedValue(null);
updateSharedLink.mockResolvedValue({ _id: 'link-456', shareId: 'share-456' });
const response = await request(buildApp()).patch('/api/share/share-123');
expect(response.status).toBe(200);
expect(updateSharedLink).toHaveBeenCalledWith('user-123', 'share-123', undefined, null);
expect(mockUpdateSharedLinkPermissionsExpiration).toHaveBeenCalledWith('link-456', null);
expect(mockSharedLinksAccess).not.toHaveBeenCalled();
});
it('preserves updated share expiration when the conversation cannot be found', async () => {
mongoose.models.SharedLink.findOne.mockReturnValue(lean({ conversationId: 'convo-123' }));
mockGetSharedLinkExpiration.mockResolvedValue(undefined);
updateSharedLink.mockResolvedValue({ shareId: 'share-456' });
const response = await request(buildApp()).patch('/api/share/share-123');
expect(response.status).toBe(200);
expect(updateSharedLink).toHaveBeenCalledWith('user-123', 'share-123', undefined, undefined);
expect(mockUpdateSharedLinkPermissionsExpiration).not.toHaveBeenCalled();
});
it('clears updated share expiration when creating a new expiration throws', async () => {
const error = new Error('bad config');
mongoose.models.SharedLink.findOne.mockReturnValue(lean({ conversationId: 'convo-123' }));
mockGetSharedLinkExpiration.mockImplementationOnce(async (_input, dependencies) => {
dependencies.logger.error('[getSharedLinkExpiration] Error creating expiration date:', error);
return null;
});
updateSharedLink.mockResolvedValue({ _id: 'link-456', shareId: 'share-456' });
const response = await request(buildApp()).patch('/api/share/share-123');
expect(response.status).toBe(200);
expect(logger.error).toHaveBeenCalledWith(
'[getSharedLinkExpiration] Error creating expiration date:',
error,
);
expect(updateSharedLink).toHaveBeenCalledWith('user-123', 'share-123', undefined, null);
expect(mockUpdateSharedLinkPermissionsExpiration).toHaveBeenCalledWith('link-456', null);
});
it('updates share target message while applying retention expiration', async () => {
mongoose.models.SharedLink.findOne.mockReturnValue(lean({ conversationId: 'convo-123' }));
mockGetSharedLinkExpiration.mockResolvedValue(activeExpiration);
updateSharedLink.mockResolvedValue({ shareId: 'share-456', targetMessageId: 'msg-456' });
const response = await request(buildApp())
.patch('/api/share/share-123')
.send({ targetMessageId: 'msg-456' });
expect(response.status).toBe(200);
expect(updateSharedLink).toHaveBeenCalledWith(
'user-123',
'share-123',
'msg-456',
new Date('2030-01-01T00:00:00.000Z'),
);
});
it('rejects non-string target message updates', async () => {
const response = await request(buildApp())
.patch('/api/share/share-123')
.send({ targetMessageId: 123 });
expect(response.status).toBe(400);
expect(updateSharedLink).not.toHaveBeenCalled();
});
it('allows deleting existing shares without CREATE permission gate', async () => {
deleteSharedLinkWithCleanup.mockResolvedValue({ shareId: 'share-123' });
const response = await request(buildApp()).delete('/api/share/share-123');
expect(response.status).toBe(200);
expect(mockSharedLinksAccess).not.toHaveBeenCalled();
expect(deleteSharedLinkWithCleanup).toHaveBeenCalledWith('user-123', 'share-123');
});
});