mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-05-14 00:19:40 +00:00
🧪 test: add \$bitsAllSet vs \$in parity + perf spec
Introduces `aclEntry.parity.spec.ts` — a side-by-side spec that runs the legacy `\$bitsAllSet` query and the current `\$in`-based query against the same `mongodb-memory-server` fixture and asserts identical output sets for every affected method (`hasPermission`, `findAccessibleResources`, `findPublicResourceIds`, `getSoleOwnedResourceIds`) across all 7 meaningful permBits combinations. Also logs median wall-clock time for the two query paths over 20 runs on an 800-entry fixture, with a loose 3x guard against catastrophic regressions. Initial local numbers: 1.05 ms vs 1.07 ms (findAccessibleResources), 1.10 ms vs 1.05 ms (findPublicResourceIds). Refs #12729.
This commit is contained in:
parent
b22df716a2
commit
a75f12219a
1 changed files with 400 additions and 0 deletions
400
packages/data-schemas/src/methods/aclEntry.parity.spec.ts
Normal file
400
packages/data-schemas/src/methods/aclEntry.parity.spec.ts
Normal file
|
|
@ -0,0 +1,400 @@
|
|||
import mongoose from 'mongoose';
|
||||
import { performance } from 'node:perf_hooks';
|
||||
import {
|
||||
ResourceType,
|
||||
PrincipalType,
|
||||
PrincipalModel,
|
||||
PermissionBits,
|
||||
} from 'librechat-data-provider';
|
||||
import { MongoMemoryServer } from 'mongodb-memory-server';
|
||||
import type * as t from '~/types';
|
||||
import { createAclEntryMethods } from './aclEntry';
|
||||
import aclEntrySchema from '~/schema/aclEntry';
|
||||
|
||||
/**
|
||||
* Parity spec — verifies the `$in`-based queries added in #12729 are behaviorally
|
||||
* equivalent to the legacy `$bitsAllSet` queries on MongoDB, and measures whether
|
||||
* the new path is competitive on wall-clock time.
|
||||
*
|
||||
* Uses `mongodb-memory-server`, which supports both `$bitsAllSet` (legacy) and
|
||||
* `$in` (new). Cosmos DB for MongoDB supports only the latter — this spec proves
|
||||
* the rewrite produces identical results on the backend we can actually run in
|
||||
* CI, then leaves the Cosmos-only behavior to manual smoke testing against a
|
||||
* real Cosmos instance.
|
||||
*/
|
||||
|
||||
let mongoServer: MongoMemoryServer;
|
||||
let AclEntry: mongoose.Model<t.IAclEntry>;
|
||||
let methods: ReturnType<typeof createAclEntryMethods>;
|
||||
|
||||
const FIXTURE_SIZE = 800;
|
||||
const PERF_ITERATIONS = 20;
|
||||
|
||||
beforeAll(async () => {
|
||||
mongoServer = await MongoMemoryServer.create();
|
||||
const mongoUri = mongoServer.getUri();
|
||||
AclEntry = mongoose.models.AclEntry || mongoose.model('AclEntry', aclEntrySchema);
|
||||
methods = createAclEntryMethods(mongoose);
|
||||
await mongoose.connect(mongoUri);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await mongoose.disconnect();
|
||||
await mongoServer.stop();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await mongoose.connection.dropDatabase();
|
||||
});
|
||||
|
||||
function idsToSortedStrings(ids: mongoose.Types.ObjectId[]): string[] {
|
||||
return ids.map((id) => id.toString()).sort();
|
||||
}
|
||||
|
||||
function buildPrincipalsQuery(
|
||||
principals: Array<{ principalType: string; principalId?: mongoose.Types.ObjectId }>,
|
||||
) {
|
||||
return principals.map((p) => ({
|
||||
principalType: p.principalType,
|
||||
...(p.principalType !== PrincipalType.PUBLIC && { principalId: p.principalId }),
|
||||
}));
|
||||
}
|
||||
|
||||
async function seedVariedPermissions(
|
||||
userId: mongoose.Types.ObjectId,
|
||||
grantedBy: mongoose.Types.ObjectId,
|
||||
count: number,
|
||||
): Promise<void> {
|
||||
/** Every permBits value from 0..15 appears roughly `count/16` times, giving
|
||||
* a uniform distribution for parity + perf assertions. */
|
||||
const docs: Partial<t.IAclEntry>[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
docs.push({
|
||||
principalType: PrincipalType.USER,
|
||||
principalId: userId,
|
||||
principalModel: PrincipalModel.USER,
|
||||
resourceType: ResourceType.AGENT,
|
||||
resourceId: new mongoose.Types.ObjectId(),
|
||||
permBits: i % 16,
|
||||
grantedBy,
|
||||
});
|
||||
}
|
||||
await AclEntry.insertMany(docs);
|
||||
}
|
||||
|
||||
async function median<T>(fn: () => Promise<T>, iterations: number): Promise<number> {
|
||||
const samples: number[] = [];
|
||||
for (let i = 0; i < iterations; i++) {
|
||||
const t0 = performance.now();
|
||||
await fn();
|
||||
samples.push(performance.now() - t0);
|
||||
}
|
||||
samples.sort((a, b) => a - b);
|
||||
return samples[Math.floor(samples.length / 2)];
|
||||
}
|
||||
|
||||
describe('ACL $bitsAllSet vs $in parity (issue #12729)', () => {
|
||||
const userId = new mongoose.Types.ObjectId();
|
||||
const grantedById = new mongoose.Types.ObjectId();
|
||||
const principalsList = [{ principalType: PrincipalType.USER, principalId: userId }];
|
||||
|
||||
describe('correctness', () => {
|
||||
test.each([
|
||||
PermissionBits.VIEW,
|
||||
PermissionBits.EDIT,
|
||||
PermissionBits.DELETE,
|
||||
PermissionBits.SHARE,
|
||||
PermissionBits.VIEW | PermissionBits.EDIT,
|
||||
PermissionBits.VIEW | PermissionBits.EDIT | PermissionBits.DELETE,
|
||||
PermissionBits.VIEW | PermissionBits.EDIT | PermissionBits.DELETE | PermissionBits.SHARE,
|
||||
])(
|
||||
'findAccessibleResources returns the same set as `$bitsAllSet` for bits=%i',
|
||||
async (bits) => {
|
||||
await seedVariedPermissions(userId, grantedById, FIXTURE_SIZE);
|
||||
|
||||
const legacy = await AclEntry.find({
|
||||
$or: buildPrincipalsQuery(principalsList),
|
||||
resourceType: ResourceType.AGENT,
|
||||
permBits: { $bitsAllSet: bits },
|
||||
}).distinct('resourceId');
|
||||
|
||||
const current = await methods.findAccessibleResources(
|
||||
principalsList,
|
||||
ResourceType.AGENT,
|
||||
bits,
|
||||
);
|
||||
|
||||
expect(idsToSortedStrings(current)).toEqual(idsToSortedStrings(legacy));
|
||||
},
|
||||
);
|
||||
|
||||
test.each([
|
||||
PermissionBits.VIEW,
|
||||
PermissionBits.EDIT | PermissionBits.DELETE,
|
||||
PermissionBits.SHARE,
|
||||
])('findPublicResourceIds returns the same set as `$bitsAllSet` for bits=%i', async (bits) => {
|
||||
/** Public entries have no principalId — seed those separately. */
|
||||
const docs: Partial<t.IAclEntry>[] = [];
|
||||
for (let i = 0; i < FIXTURE_SIZE; i++) {
|
||||
docs.push({
|
||||
principalType: PrincipalType.PUBLIC,
|
||||
resourceType: ResourceType.AGENT,
|
||||
resourceId: new mongoose.Types.ObjectId(),
|
||||
permBits: i % 16,
|
||||
grantedBy: grantedById,
|
||||
});
|
||||
}
|
||||
await AclEntry.insertMany(docs);
|
||||
|
||||
const legacy = await AclEntry.find({
|
||||
principalType: PrincipalType.PUBLIC,
|
||||
resourceType: ResourceType.AGENT,
|
||||
permBits: { $bitsAllSet: bits },
|
||||
}).distinct('resourceId');
|
||||
|
||||
const current = await methods.findPublicResourceIds(ResourceType.AGENT, bits);
|
||||
|
||||
expect(idsToSortedStrings(current)).toEqual(idsToSortedStrings(legacy));
|
||||
});
|
||||
|
||||
test('hasPermission returns the same boolean as `$bitsAllSet` across all permBits patterns', async () => {
|
||||
const sharedResourceId = new mongoose.Types.ObjectId();
|
||||
/** One entry per permBits value (0..15) on the same resource. */
|
||||
for (let permBits = 0; permBits <= 15; permBits++) {
|
||||
await AclEntry.create({
|
||||
principalType: PrincipalType.USER,
|
||||
principalId: new mongoose.Types.ObjectId(),
|
||||
principalModel: PrincipalModel.USER,
|
||||
resourceType: ResourceType.AGENT,
|
||||
resourceId: sharedResourceId,
|
||||
permBits,
|
||||
grantedBy: grantedById,
|
||||
});
|
||||
}
|
||||
|
||||
/** For each required bit, enumerate which principalIds satisfy the mask
|
||||
* via legacy and current and verify they match. */
|
||||
for (const required of [
|
||||
PermissionBits.VIEW,
|
||||
PermissionBits.EDIT,
|
||||
PermissionBits.DELETE,
|
||||
PermissionBits.SHARE,
|
||||
PermissionBits.VIEW | PermissionBits.EDIT,
|
||||
PermissionBits.VIEW | PermissionBits.EDIT | PermissionBits.DELETE | PermissionBits.SHARE,
|
||||
]) {
|
||||
const legacyMatches = await AclEntry.find({
|
||||
resourceType: ResourceType.AGENT,
|
||||
resourceId: sharedResourceId,
|
||||
permBits: { $bitsAllSet: required },
|
||||
})
|
||||
.select('principalId')
|
||||
.lean();
|
||||
|
||||
for (const entry of legacyMatches) {
|
||||
const pid = entry.principalId as mongoose.Types.ObjectId;
|
||||
const current = await methods.hasPermission(
|
||||
[{ principalType: PrincipalType.USER, principalId: pid }],
|
||||
ResourceType.AGENT,
|
||||
sharedResourceId,
|
||||
required,
|
||||
);
|
||||
expect(current).toBe(true);
|
||||
}
|
||||
|
||||
const allEntries = await AclEntry.find({
|
||||
resourceType: ResourceType.AGENT,
|
||||
resourceId: sharedResourceId,
|
||||
})
|
||||
.select('principalId permBits')
|
||||
.lean();
|
||||
const matchingIds = new Set(
|
||||
legacyMatches.map((e) => (e.principalId as mongoose.Types.ObjectId).toString()),
|
||||
);
|
||||
for (const entry of allEntries) {
|
||||
if (matchingIds.has((entry.principalId as mongoose.Types.ObjectId).toString())) continue;
|
||||
const current = await methods.hasPermission(
|
||||
[
|
||||
{
|
||||
principalType: PrincipalType.USER,
|
||||
principalId: entry.principalId as mongoose.Types.ObjectId,
|
||||
},
|
||||
],
|
||||
ResourceType.AGENT,
|
||||
sharedResourceId,
|
||||
required,
|
||||
);
|
||||
expect(current).toBe(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('getSoleOwnedResourceIds returns the same set as the legacy aggregation', async () => {
|
||||
/** Seed a mixed fixture where `userId` owns (has DELETE on) some resources
|
||||
* solely, shares ownership of others, and lacks DELETE on a third group. */
|
||||
const soleOwnedA = new mongoose.Types.ObjectId();
|
||||
const soleOwnedB = new mongoose.Types.ObjectId();
|
||||
const sharedOwned = new mongoose.Types.ObjectId();
|
||||
const nonOwnedView = new mongoose.Types.ObjectId();
|
||||
|
||||
const otherUserId = new mongoose.Types.ObjectId();
|
||||
|
||||
await AclEntry.insertMany([
|
||||
{
|
||||
principalType: PrincipalType.USER,
|
||||
principalId: userId,
|
||||
principalModel: PrincipalModel.USER,
|
||||
resourceType: ResourceType.AGENT,
|
||||
resourceId: soleOwnedA,
|
||||
permBits: PermissionBits.VIEW | PermissionBits.EDIT | PermissionBits.DELETE,
|
||||
grantedBy: grantedById,
|
||||
},
|
||||
{
|
||||
principalType: PrincipalType.USER,
|
||||
principalId: userId,
|
||||
principalModel: PrincipalModel.USER,
|
||||
resourceType: ResourceType.AGENT,
|
||||
resourceId: soleOwnedB,
|
||||
permBits: PermissionBits.DELETE | PermissionBits.SHARE,
|
||||
grantedBy: grantedById,
|
||||
},
|
||||
{
|
||||
principalType: PrincipalType.USER,
|
||||
principalId: userId,
|
||||
principalModel: PrincipalModel.USER,
|
||||
resourceType: ResourceType.AGENT,
|
||||
resourceId: sharedOwned,
|
||||
permBits: PermissionBits.VIEW | PermissionBits.EDIT | PermissionBits.DELETE,
|
||||
grantedBy: grantedById,
|
||||
},
|
||||
{
|
||||
principalType: PrincipalType.USER,
|
||||
principalId: otherUserId,
|
||||
principalModel: PrincipalModel.USER,
|
||||
resourceType: ResourceType.AGENT,
|
||||
resourceId: sharedOwned,
|
||||
permBits: PermissionBits.VIEW | PermissionBits.DELETE,
|
||||
grantedBy: grantedById,
|
||||
},
|
||||
{
|
||||
principalType: PrincipalType.USER,
|
||||
principalId: userId,
|
||||
principalModel: PrincipalModel.USER,
|
||||
resourceType: ResourceType.AGENT,
|
||||
resourceId: nonOwnedView,
|
||||
permBits: PermissionBits.VIEW | PermissionBits.EDIT,
|
||||
grantedBy: grantedById,
|
||||
},
|
||||
]);
|
||||
|
||||
/** Legacy reference: the original two-pass algorithm from #11830. */
|
||||
const legacyOwned = await AclEntry.find({
|
||||
principalType: PrincipalType.USER,
|
||||
principalId: userId,
|
||||
resourceType: { $in: [ResourceType.AGENT] },
|
||||
permBits: { $bitsAllSet: PermissionBits.DELETE },
|
||||
})
|
||||
.select('resourceId')
|
||||
.lean();
|
||||
const legacyIds = legacyOwned.map((e) => e.resourceId);
|
||||
const legacyOthers = await AclEntry.aggregate([
|
||||
{
|
||||
$match: {
|
||||
resourceType: { $in: [ResourceType.AGENT] },
|
||||
resourceId: { $in: legacyIds },
|
||||
permBits: { $bitsAllSet: PermissionBits.DELETE },
|
||||
$or: [{ principalId: { $ne: userId } }, { principalType: { $ne: PrincipalType.USER } }],
|
||||
},
|
||||
},
|
||||
{ $group: { _id: '$resourceId' } },
|
||||
]);
|
||||
const legacyMultiOwner = new Set(
|
||||
legacyOthers.map((doc: { _id: mongoose.Types.ObjectId }) => doc._id.toString()),
|
||||
);
|
||||
const legacyResult = legacyIds.filter((id) => !legacyMultiOwner.has(id.toString()));
|
||||
|
||||
const current = await methods.getSoleOwnedResourceIds(userId, ResourceType.AGENT);
|
||||
|
||||
expect(idsToSortedStrings(current)).toEqual(idsToSortedStrings(legacyResult));
|
||||
expect(idsToSortedStrings(current)).toEqual(
|
||||
[soleOwnedA.toString(), soleOwnedB.toString()].sort(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('performance (wall-clock, median over 20 runs)', () => {
|
||||
/** These tests don't assert on absolute timings (CI variance) — they log
|
||||
* numbers so reviewers can verify no gross regression and, if curious,
|
||||
* see the `$in` path is typically at least as fast as `$bitsAllSet`
|
||||
* because `$in` is indexable while `$bitsAllSet` is not. */
|
||||
test('findAccessibleResources: $bitsAllSet vs $in', async () => {
|
||||
await seedVariedPermissions(userId, grantedById, FIXTURE_SIZE);
|
||||
const requiredBits = PermissionBits.VIEW | PermissionBits.EDIT;
|
||||
|
||||
const legacyMs = await median(
|
||||
() =>
|
||||
AclEntry.find({
|
||||
$or: buildPrincipalsQuery(principalsList),
|
||||
resourceType: ResourceType.AGENT,
|
||||
permBits: { $bitsAllSet: requiredBits },
|
||||
})
|
||||
.distinct('resourceId')
|
||||
.then(() => void 0),
|
||||
PERF_ITERATIONS,
|
||||
);
|
||||
const currentMs = await median(
|
||||
() =>
|
||||
methods
|
||||
.findAccessibleResources(principalsList, ResourceType.AGENT, requiredBits)
|
||||
.then(() => void 0),
|
||||
PERF_ITERATIONS,
|
||||
);
|
||||
|
||||
console.log(
|
||||
`[perf] findAccessibleResources — legacy $bitsAllSet: ${legacyMs.toFixed(2)}ms, ` +
|
||||
`current $in: ${currentMs.toFixed(2)}ms (median of ${PERF_ITERATIONS} runs, ${FIXTURE_SIZE} entries)`,
|
||||
);
|
||||
/** Sanity check: the new path should not be dramatically slower. A 3x
|
||||
* multiplier catches catastrophic regressions (e.g. missing index use)
|
||||
* without flaking on normal CI variance. */
|
||||
expect(currentMs).toBeLessThan(legacyMs * 3 + 50);
|
||||
});
|
||||
|
||||
test('findPublicResourceIds: $bitsAllSet vs $in', async () => {
|
||||
const docs: Partial<t.IAclEntry>[] = [];
|
||||
for (let i = 0; i < FIXTURE_SIZE; i++) {
|
||||
docs.push({
|
||||
principalType: PrincipalType.PUBLIC,
|
||||
resourceType: ResourceType.AGENT,
|
||||
resourceId: new mongoose.Types.ObjectId(),
|
||||
permBits: i % 16,
|
||||
grantedBy: grantedById,
|
||||
});
|
||||
}
|
||||
await AclEntry.insertMany(docs);
|
||||
|
||||
const requiredBits = PermissionBits.VIEW;
|
||||
|
||||
const legacyMs = await median(
|
||||
() =>
|
||||
AclEntry.find({
|
||||
principalType: PrincipalType.PUBLIC,
|
||||
resourceType: ResourceType.AGENT,
|
||||
permBits: { $bitsAllSet: requiredBits },
|
||||
})
|
||||
.distinct('resourceId')
|
||||
.then(() => void 0),
|
||||
PERF_ITERATIONS,
|
||||
);
|
||||
const currentMs = await median(
|
||||
() => methods.findPublicResourceIds(ResourceType.AGENT, requiredBits).then(() => void 0),
|
||||
PERF_ITERATIONS,
|
||||
);
|
||||
|
||||
console.log(
|
||||
`[perf] findPublicResourceIds — legacy $bitsAllSet: ${legacyMs.toFixed(2)}ms, ` +
|
||||
`current $in: ${currentMs.toFixed(2)}ms (median of ${PERF_ITERATIONS} runs, ${FIXTURE_SIZE} entries)`,
|
||||
);
|
||||
expect(currentMs).toBeLessThan(legacyMs * 3 + 50);
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue