LibreChat/config/__tests__/migrate-shared-link-permissions.spec.js
Atef Bellaaj 86fe79c37d
🔗 feat: Add Granular Access Control to Shared Links via ACL System (#13051)
* 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>
2026-06-03 14:17:17 -04:00

218 lines
7.5 KiB
JavaScript

jest.mock('../connect', () => jest.fn().mockResolvedValue(true));
jest.mock('@librechat/api', () => ({
ensureRequiredCollectionsExist: jest.fn().mockResolvedValue(undefined),
matchModelName: jest.fn(),
findMatchingPattern: jest.fn(),
}));
jest.mock('@librechat/data-schemas', () => ({
...jest.requireActual('@librechat/data-schemas'),
logger: { error: jest.fn(), info: jest.fn(), warn: jest.fn(), debug: jest.fn() },
}));
jest.mock('~/cache/getLogStores', () => jest.fn());
const mongoose = require('mongoose');
const { MongoMemoryServer } = require('mongodb-memory-server');
const { createMethods, SYSTEM_TENANT_ID, tenantStorage } = require('@librechat/data-schemas');
describe('migrate-shared-link-permissions', () => {
let mongoServer;
let SharedLink, AclEntry;
let migrateSharedLinkPermissions;
const testUserId = new mongoose.Types.ObjectId();
beforeAll(async () => {
mongoServer = await MongoMemoryServer.create();
await mongoose.connect(mongoServer.getUri());
const migration = require('../migrate-shared-link-permissions');
migrateSharedLinkPermissions = migration.migrateSharedLinkPermissions;
SharedLink = mongoose.models.SharedLink;
AclEntry = mongoose.models.AclEntry;
await createMethods(mongoose).seedDefaultRoles();
});
afterAll(async () => {
await mongoose.disconnect();
await mongoServer.stop();
});
beforeEach(async () => {
await AclEntry.deleteMany({});
await SharedLink.deleteMany({});
jest.restoreAllMocks();
});
async function createLegacyLink(isPublic = true, user = testUserId, overrides = {}) {
const link = await SharedLink.create({
shareId: `share-${Date.now()}-${Math.random()}`,
conversationId: 'convo1',
messages: [],
...(user != null ? { user } : {}),
...overrides,
});
await mongoose.connection.db
.collection('sharedlinks')
.updateOne({ _id: link._id }, { $set: { isPublic } });
return link;
}
test('removes isPublic from all links on success', async () => {
const link1 = await createLegacyLink(true);
const link2 = await createLegacyLink(true);
const result = await migrateSharedLinkPermissions({ dryRun: false, batchSize: 100 });
expect(result.errors).toBe(0);
expect(result.failedLinkCount).toBe(0);
const raw1 = await mongoose.connection.db.collection('sharedlinks').findOne({ _id: link1._id });
const raw2 = await mongoose.connection.db.collection('sharedlinks').findOne({ _id: link2._id });
expect(raw1).not.toHaveProperty('isPublic');
expect(raw2).not.toHaveProperty('isPublic');
});
test('preserves isPublic on links with partial write errors', async () => {
const link1 = await createLegacyLink(true);
const link2 = await createLegacyLink(true);
const link3 = await createLegacyLink(true);
const originalBulkWrite = AclEntry.bulkWrite.bind(AclEntry);
jest.spyOn(AclEntry, 'bulkWrite').mockImplementationOnce(async (ops, options) => {
const failedIndices = [];
const successOps = [];
for (let i = 0; i < ops.length; i++) {
if (ops[i].updateOne.filter.resourceId.equals(link2._id)) {
failedIndices.push(i);
} else {
successOps.push(ops[i]);
}
}
if (successOps.length > 0) {
await originalBulkWrite(successOps, options);
}
const error = new Error('Partial bulk write failure');
error.writeErrors = failedIndices.map((idx) => ({
index: idx,
errmsg: 'Simulated write error',
}));
throw error;
});
const result = await migrateSharedLinkPermissions({ dryRun: false, batchSize: 100 });
expect(result.failedLinkCount).toBe(1);
expect(result.errors).toBeGreaterThan(0);
const raw1 = await mongoose.connection.db.collection('sharedlinks').findOne({ _id: link1._id });
const raw2 = await mongoose.connection.db.collection('sharedlinks').findOne({ _id: link2._id });
const raw3 = await mongoose.connection.db.collection('sharedlinks').findOne({ _id: link3._id });
expect(raw2).toHaveProperty('isPublic', true);
expect(raw1).not.toHaveProperty('isPublic');
expect(raw3).not.toHaveProperty('isPublic');
});
test('preserves isPublic on all batch links when bulk write fails entirely', async () => {
const link1 = await createLegacyLink(true);
const link2 = await createLegacyLink(true);
jest.spyOn(AclEntry, 'bulkWrite').mockRejectedValueOnce(new Error('Connection lost'));
const result = await migrateSharedLinkPermissions({ dryRun: false, batchSize: 100 });
expect(result.failedLinkCount).toBe(2);
const raw1 = await mongoose.connection.db.collection('sharedlinks').findOne({ _id: link1._id });
const raw2 = await mongoose.connection.db.collection('sharedlinks').findOne({ _id: link2._id });
expect(raw1).toHaveProperty('isPublic', true);
expect(raw2).toHaveProperty('isPublic', true);
});
test('does not grant PUBLIC VIEWER to isPublic false links when forced', async () => {
const link = await createLegacyLink(false);
const result = await migrateSharedLinkPermissions({ dryRun: false, force: true });
expect(result.aborted).toBeUndefined();
expect(result.publicViewerSkipped).toBe(1);
const publicEntry = await AclEntry.findOne({
resourceId: link._id,
principalType: 'public',
}).lean();
expect(publicEntry).toBeNull();
const ownerEntry = await AclEntry.findOne({
resourceId: link._id,
principalType: 'user',
}).lean();
expect(ownerEntry).toBeDefined();
});
test('reports private legacy links during dry run without aborting', async () => {
await createLegacyLink(false);
const result = await migrateSharedLinkPermissions({ dryRun: true });
expect(result.aborted).toBeUndefined();
expect(result.dryRun).toBe(true);
expect(result.summary.withIsPublicFalse).toBe(1);
});
test('grants PUBLIC VIEWER to ownerless public legacy links', async () => {
const link = await createLegacyLink(true, null);
const result = await migrateSharedLinkPermissions({ dryRun: false });
expect(result.missingUserWarnings).toBe(1);
const publicEntry = await AclEntry.findOne({
resourceId: link._id,
principalType: 'public',
}).lean();
expect(publicEntry).toBeDefined();
expect(publicEntry).not.toHaveProperty('grantedBy');
const ownerEntry = await AclEntry.findOne({
resourceId: link._id,
principalType: 'user',
}).lean();
expect(ownerEntry).toBeNull();
});
test('copies shared link expiration to migrated ACL entries', async () => {
const expiredAt = new Date(Date.now() + 60 * 60 * 1000);
const link = await createLegacyLink(true, testUserId, { expiredAt });
await migrateSharedLinkPermissions({ dryRun: false });
const entries = await AclEntry.find({ resourceId: link._id }).lean();
expect(entries).toHaveLength(2);
for (const entry of entries) {
expect(entry.expiredAt?.toISOString()).toBe(expiredAt.toISOString());
}
});
test('runs the migration body inside a system tenant context', async () => {
await createLegacyLink(true);
const contextsObserved = [];
const originalCountDocuments = SharedLink.countDocuments.bind(SharedLink);
SharedLink.countDocuments = jest.fn((...args) => {
contextsObserved.push(tenantStorage.getStore()?.tenantId);
return originalCountDocuments(...args);
});
try {
await migrateSharedLinkPermissions({ dryRun: true });
expect(contextsObserved).toContain(SYSTEM_TENANT_ID);
} finally {
SharedLink.countDocuments = originalCountDocuments;
}
});
});