mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-09-06 14:39:10 +00:00
* feat: Add granular access control to shared links via ACL system * fix(shared-links): preserve isPublic on failed migration grants Transient ACL failures during auto-migration permanently stranded links — $unset ran unconditionally, removing the legacy flag that triggers retry. Now only $unset isPublic after all grants succeed. * fix(config): skip isPublic unset for failed ACL grants Bulk migration unconditionally removed isPublic from all links, even those whose ACL writes failed. Failed links then lost the legacy marker needed for auto-migration retry. Now tracks failed link IDs per-batch and excludes them from the $unset step. Also adds sharedLink to AccessRole resourceType schema enum — was missing, only worked because seedDefaultRoles uses findOneAndUpdate which bypasses validation. * ci(config): add jest config and PR workflow for migration tests config/__tests__/ specs depend on api/jest.config.js module mappings but had no dedicated runner. Adds config/jest.config.js extending api config with absolutized paths, npm test:config script, and a GitHub Actions workflow triggered by changes to config/, api/models/, api/db/, or packages/ ACL code. * fix(permissions): honor boolean sharedLinks config SHARED_LINKS has no USE permission, so boolean config produced an empty update payload — gate conditions only matched object form, making `sharedLinks: false` a no-op on existing perms. * fix(share): resolve role before creating shared link Role lookup between create and grant left an orphaned link without ACL entries if getRoleByName threw — retry then hit "Share already exists" with no recovery path. * fix: Restore Public ACL Access Checks * fix: Type Public ACL Lookup * fix: Preserve Private Legacy Shared Links * chore: Promote Shared Link Permission Migration * fix: Address Shared Link Review Findings * fix: Repair Shared Link CI Follow-Up * fix: Narrow Shared Link Mongoose Test Mock * fix: Address Shared Link Review Follow-Ups * fix: Close Shared Link Review Gaps * fix: Guard Missing Shared Link Permission Backfill * test: Add Shared Link Mock E2E * test: Stabilize Shared Link Mock E2E --------- Co-authored-by: Danny Avila <danny@librechat.ai>
315 lines
12 KiB
JavaScript
315 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 { 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('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');
|
|
});
|
|
});
|