mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-07-10 16:23:44 +00:00
455 lines
13 KiB
JavaScript
455 lines
13 KiB
JavaScript
const mockGetAppConfig = jest.fn();
|
|
const mockGetStrategyFunctions = jest.fn();
|
|
const mockGetFileStrategy = jest.fn();
|
|
const mockFindRoleByIdentifier = jest.fn();
|
|
const mockGrantPermission = jest.fn();
|
|
let mockRunnerDeps;
|
|
let mockRunnerStatus;
|
|
const mockCreatedRunners = [];
|
|
|
|
jest.mock('~/server/services/Config', () => ({
|
|
getAppConfig: mockGetAppConfig,
|
|
}));
|
|
|
|
jest.mock('@librechat/api', () => ({
|
|
createGitHubSkillSyncRunner: jest.fn((deps) => {
|
|
mockRunnerDeps = deps;
|
|
const runner = {
|
|
getStatus: jest.fn(async () => {
|
|
if (mockRunnerStatus) {
|
|
return mockRunnerStatus;
|
|
}
|
|
const config = await deps.getConfig();
|
|
const github = config?.github ?? {};
|
|
return {
|
|
enabled: github.enabled ?? false,
|
|
intervalMinutes: github.intervalMinutes ?? 60,
|
|
runOnStartup: github.runOnStartup ?? false,
|
|
sources: (github.sources ?? []).map((source) => ({
|
|
provider: 'github',
|
|
sourceId: source.id,
|
|
status: 'idle',
|
|
owner: source.owner,
|
|
repo: source.repo,
|
|
ref: source.ref,
|
|
paths: source.paths,
|
|
syncedSkillCount: 0,
|
|
syncedFileCount: 0,
|
|
deletedSkillCount: 0,
|
|
deletedFileCount: 0,
|
|
})),
|
|
credentials: [],
|
|
};
|
|
}),
|
|
runOnce: jest.fn(async () => deps.getConfig()),
|
|
};
|
|
mockCreatedRunners.push({ deps, runner });
|
|
return runner;
|
|
}),
|
|
getStorageMetadata: jest.fn(() => ({})),
|
|
startGitHubSkillSyncScheduler: jest.fn(() => ({ stop: jest.fn() })),
|
|
}));
|
|
|
|
jest.mock('@librechat/data-schemas', () => ({
|
|
logger: {
|
|
error: jest.fn(),
|
|
warn: jest.fn(),
|
|
},
|
|
runAsSystem: jest.fn((fn) => fn()),
|
|
}));
|
|
|
|
jest.mock('~/models', () => ({
|
|
findRoleByIdentifier: mockFindRoleByIdentifier,
|
|
grantPermission: mockGrantPermission,
|
|
getSkillSyncCredentialToken: jest.fn(),
|
|
getSkillSyncCredentialSummary: jest.fn(),
|
|
listSkillSyncCredentials: jest.fn(async () => []),
|
|
listSkillSyncStatuses: jest.fn(async () => []),
|
|
upsertSkillSyncStatus: jest.fn(),
|
|
tryAcquireSkillSyncLock: jest.fn(),
|
|
refreshSkillSyncLock: jest.fn(),
|
|
releaseSkillSyncLock: jest.fn(),
|
|
createSkill: jest.fn(),
|
|
updateSkill: jest.fn(),
|
|
getSkillById: jest.fn(),
|
|
findSkillBySourceIdentity: jest.fn(),
|
|
listSkillsBySource: jest.fn(),
|
|
listSkillFiles: jest.fn(),
|
|
getSkillFileByPath: jest.fn(),
|
|
upsertSkillFile: jest.fn(),
|
|
deleteSkillFile: jest.fn(),
|
|
deleteSkill: jest.fn(),
|
|
}));
|
|
jest.mock('~/server/services/Files/strategies', () => ({
|
|
getStrategyFunctions: mockGetStrategyFunctions,
|
|
}));
|
|
jest.mock('~/server/utils/getFileStrategy', () => ({ getFileStrategy: mockGetFileStrategy }));
|
|
|
|
describe('GitHub skill sync service', () => {
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
mockGetAppConfig.mockReset();
|
|
mockGetStrategyFunctions.mockReset();
|
|
mockGetFileStrategy.mockReset();
|
|
mockFindRoleByIdentifier.mockReset();
|
|
mockGrantPermission.mockReset();
|
|
mockRunnerDeps = undefined;
|
|
mockRunnerStatus = undefined;
|
|
mockCreatedRunners.length = 0;
|
|
});
|
|
|
|
it('resolves sync config from fresh base app config for runner operations', async () => {
|
|
const startupSkillSync = {
|
|
github: {
|
|
enabled: false,
|
|
intervalMinutes: 60,
|
|
runOnStartup: false,
|
|
sources: [],
|
|
},
|
|
};
|
|
const freshSkillSync = {
|
|
github: {
|
|
enabled: true,
|
|
intervalMinutes: 5,
|
|
runOnStartup: false,
|
|
sources: [
|
|
{
|
|
id: 'librechat-skills',
|
|
owner: 'LibreChat',
|
|
repo: 'skills',
|
|
ref: 'main',
|
|
paths: ['skills'],
|
|
credentialKey: 'github-skills-prod',
|
|
},
|
|
],
|
|
},
|
|
};
|
|
mockGetAppConfig.mockResolvedValue({ skillSync: freshSkillSync });
|
|
|
|
const service = require('./sync');
|
|
const { runner } = service.initializeGitHubSkillSync({ skillSync: startupSkillSync });
|
|
const result = await runner.runOnce();
|
|
|
|
expect(result).toBe(freshSkillSync);
|
|
expect(mockRunnerDeps.getConfig).toBeDefined();
|
|
expect(mockGetAppConfig).toHaveBeenCalledWith({ baseOnly: true });
|
|
});
|
|
|
|
it('does not return raw unvalidated config.skillSync as sync config', async () => {
|
|
const rawSkillSync = {
|
|
github: {
|
|
enabled: true,
|
|
sources: 'not-an-array',
|
|
},
|
|
};
|
|
mockGetAppConfig.mockResolvedValue({ config: { skillSync: rawSkillSync } });
|
|
|
|
const service = require('./sync');
|
|
const { runner } = service.initializeGitHubSkillSync({ config: { skillSync: rawSkillSync } });
|
|
const result = await runner.runOnce();
|
|
|
|
expect(result).toBeUndefined();
|
|
expect(mockGetAppConfig).toHaveBeenCalledWith({ baseOnly: true });
|
|
});
|
|
|
|
it('starts a request-scoped sync for resolved admin skillSync config', async () => {
|
|
const skillSync = {
|
|
github: {
|
|
enabled: true,
|
|
intervalMinutes: 60,
|
|
runOnStartup: true,
|
|
sources: [
|
|
{
|
|
id: 'tenant-skills',
|
|
owner: 'LibreChat',
|
|
repo: 'skills',
|
|
ref: 'main',
|
|
paths: ['skills'],
|
|
token: '${GITHUB_SKILLS_TOKEN}',
|
|
tenantId: 'other-tenant',
|
|
},
|
|
],
|
|
},
|
|
};
|
|
|
|
const service = require('./sync');
|
|
const started = await service.maybeRunGitHubSkillSyncForRequest({
|
|
config: { skillSync, config: {} },
|
|
user: { id: 'user-1', tenantId: 'tenant-a' },
|
|
});
|
|
|
|
const requestRunner = mockCreatedRunners[0].runner;
|
|
const requestConfig = await mockCreatedRunners[0].deps.getConfig();
|
|
expect(started).toBe(true);
|
|
expect(requestRunner.runOnce).toHaveBeenCalledTimes(1);
|
|
expect(requestConfig.github.runOnStartup).toBe(false);
|
|
expect(requestConfig.github.sources[0]).toEqual(
|
|
expect.objectContaining({
|
|
id: 'tenant-skills',
|
|
tenantId: 'tenant-a',
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('does not start a request-scoped sync for base YAML skillSync config', async () => {
|
|
const skillSync = {
|
|
github: {
|
|
enabled: true,
|
|
intervalMinutes: 60,
|
|
runOnStartup: true,
|
|
sources: [
|
|
{
|
|
id: 'base-skills',
|
|
owner: 'LibreChat',
|
|
repo: 'skills',
|
|
ref: 'main',
|
|
paths: ['skills'],
|
|
token: '${GITHUB_SKILLS_TOKEN}',
|
|
},
|
|
],
|
|
},
|
|
};
|
|
|
|
const service = require('./sync');
|
|
const started = await service.maybeRunGitHubSkillSyncForRequest({
|
|
config: { skillSync, config: { skillSync } },
|
|
user: { id: 'user-1', tenantId: 'tenant-a' },
|
|
});
|
|
|
|
expect(started).toBe(false);
|
|
expect(mockCreatedRunners).toHaveLength(0);
|
|
});
|
|
|
|
it('creates an admin request runner from resolved skillSync config overrides', async () => {
|
|
const skillSync = {
|
|
github: {
|
|
enabled: true,
|
|
intervalMinutes: 60,
|
|
runOnStartup: true,
|
|
sources: [
|
|
{
|
|
id: 'tenant-skills',
|
|
owner: 'LibreChat',
|
|
repo: 'skills',
|
|
ref: 'main',
|
|
paths: ['skills'],
|
|
token: '${GITHUB_SKILLS_TOKEN}',
|
|
tenantId: 'other-tenant',
|
|
},
|
|
],
|
|
},
|
|
};
|
|
|
|
const service = require('./sync');
|
|
const runner = service.getGitHubSkillSyncRunnerForRequest({
|
|
config: { skillSync, config: {} },
|
|
user: { id: 'user-1', tenantId: 'tenant-a' },
|
|
});
|
|
const config = await mockCreatedRunners[0].deps.getConfig();
|
|
|
|
expect(runner.runOnce).toBe(mockCreatedRunners[0].runner.runOnce);
|
|
expect(runner.getStatus).toBe(mockCreatedRunners[0].runner.getStatus);
|
|
expect(config.github.runOnStartup).toBe(true);
|
|
expect(config.github.sources[0]).toEqual(
|
|
expect.objectContaining({ id: 'tenant-skills', tenantId: 'tenant-a' }),
|
|
);
|
|
});
|
|
|
|
it('does not start a request-scoped sync when the configured source is already running', async () => {
|
|
const skillSync = {
|
|
github: {
|
|
enabled: true,
|
|
intervalMinutes: 60,
|
|
runOnStartup: false,
|
|
sources: [
|
|
{
|
|
id: 'tenant-skills',
|
|
owner: 'LibreChat',
|
|
repo: 'skills',
|
|
ref: 'main',
|
|
paths: ['skills'],
|
|
token: '${GITHUB_SKILLS_TOKEN}',
|
|
},
|
|
],
|
|
},
|
|
};
|
|
mockRunnerStatus = {
|
|
enabled: true,
|
|
intervalMinutes: 60,
|
|
runOnStartup: false,
|
|
sources: [
|
|
{
|
|
provider: 'github',
|
|
sourceId: 'tenant-skills',
|
|
status: 'running',
|
|
owner: 'LibreChat',
|
|
repo: 'skills',
|
|
ref: 'main',
|
|
paths: ['skills'],
|
|
startedAt: new Date(),
|
|
syncedSkillCount: 0,
|
|
syncedFileCount: 0,
|
|
deletedSkillCount: 0,
|
|
deletedFileCount: 0,
|
|
},
|
|
],
|
|
credentials: [],
|
|
};
|
|
|
|
const service = require('./sync');
|
|
const started = await service.maybeRunGitHubSkillSyncForRequest({
|
|
config: { skillSync, config: {} },
|
|
user: { id: 'user-1', tenantId: 'tenant-a' },
|
|
});
|
|
|
|
expect(started).toBe(false);
|
|
expect(mockCreatedRunners[0].runner.runOnce).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('retries a request-scoped sync when a running source status is stale', async () => {
|
|
const skillSync = {
|
|
github: {
|
|
enabled: true,
|
|
intervalMinutes: 60,
|
|
runOnStartup: false,
|
|
sources: [
|
|
{
|
|
id: 'tenant-skills',
|
|
owner: 'LibreChat',
|
|
repo: 'skills',
|
|
ref: 'main',
|
|
paths: ['skills'],
|
|
token: '${GITHUB_SKILLS_TOKEN}',
|
|
},
|
|
],
|
|
},
|
|
};
|
|
mockRunnerStatus = {
|
|
enabled: true,
|
|
intervalMinutes: 60,
|
|
runOnStartup: false,
|
|
sources: [
|
|
{
|
|
provider: 'github',
|
|
sourceId: 'tenant-skills',
|
|
status: 'running',
|
|
owner: 'LibreChat',
|
|
repo: 'skills',
|
|
ref: 'main',
|
|
paths: ['skills'],
|
|
startedAt: new Date(Date.now() - 40 * 60 * 1000),
|
|
syncedSkillCount: 0,
|
|
syncedFileCount: 0,
|
|
deletedSkillCount: 0,
|
|
deletedFileCount: 0,
|
|
},
|
|
],
|
|
credentials: [],
|
|
};
|
|
|
|
const service = require('./sync');
|
|
const started = await service.maybeRunGitHubSkillSyncForRequest({
|
|
config: { skillSync, config: {} },
|
|
user: { id: 'user-1', tenantId: 'tenant-a' },
|
|
});
|
|
|
|
expect(started).toBe(true);
|
|
expect(mockCreatedRunners[0].runner.runOnce).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('uses the file owner when deleting synced files from storage', async () => {
|
|
const deleteFile = jest.fn(async () => undefined);
|
|
const ownerId = '507f1f77bcf86cd799439011';
|
|
mockGetAppConfig.mockResolvedValue({ skillSync: undefined, paths: {} });
|
|
mockGetStrategyFunctions.mockReturnValue({ deleteFile });
|
|
|
|
const service = require('./sync');
|
|
service.initializeGitHubSkillSync({ skillSync: undefined });
|
|
await mockRunnerDeps.deleteFile({
|
|
filepath: `/uploads/${ownerId}/file.txt`,
|
|
source: 'local',
|
|
user: ownerId,
|
|
tenantId: 'tenant-a',
|
|
});
|
|
|
|
expect(deleteFile).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
user: expect.objectContaining({
|
|
id: ownerId,
|
|
_id: ownerId,
|
|
tenantId: 'tenant-a',
|
|
}),
|
|
}),
|
|
expect.objectContaining({
|
|
user: ownerId,
|
|
tenantId: 'tenant-a',
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('does not force manual sync runs into the system tenant context', async () => {
|
|
const { runAsSystem } = require('@librechat/data-schemas');
|
|
mockGetAppConfig.mockResolvedValue({ skillSync: undefined, paths: {} });
|
|
|
|
const service = require('./sync');
|
|
const { runner } = service.initializeGitHubSkillSync({ skillSync: undefined });
|
|
await runner.runOnce();
|
|
|
|
expect(runAsSystem).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('resolves the access role outside tenant isolation but writes the ACL in context', async () => {
|
|
const { runAsSystem } = require('@librechat/data-schemas');
|
|
mockGetAppConfig.mockResolvedValue({ skillSync: undefined, paths: {} });
|
|
mockFindRoleByIdentifier.mockResolvedValue({
|
|
_id: 'role-object-id',
|
|
resourceType: 'skill',
|
|
permBits: 1,
|
|
});
|
|
mockGrantPermission.mockResolvedValue({ _id: 'acl-entry-id' });
|
|
|
|
const service = require('./sync');
|
|
service.initializeGitHubSkillSync({ skillSync: undefined });
|
|
await mockRunnerDeps.grantPermission({
|
|
principalType: 'public',
|
|
principalId: null,
|
|
resourceType: 'skill',
|
|
resourceId: 'skill-id',
|
|
accessRoleId: 'skill_viewer',
|
|
grantedBy: 'system',
|
|
});
|
|
|
|
expect(runAsSystem).toHaveBeenCalledTimes(1);
|
|
expect(mockFindRoleByIdentifier).toHaveBeenCalledWith('skill_viewer');
|
|
expect(mockGrantPermission).toHaveBeenCalledWith(
|
|
'public',
|
|
null,
|
|
'skill',
|
|
'skill-id',
|
|
1,
|
|
'system',
|
|
undefined,
|
|
'role-object-id',
|
|
);
|
|
});
|
|
|
|
it('fails the grant when the access role does not exist', async () => {
|
|
mockGetAppConfig.mockResolvedValue({ skillSync: undefined, paths: {} });
|
|
mockFindRoleByIdentifier.mockResolvedValue(null);
|
|
|
|
const service = require('./sync');
|
|
service.initializeGitHubSkillSync({ skillSync: undefined });
|
|
|
|
await expect(
|
|
mockRunnerDeps.grantPermission({
|
|
principalType: 'public',
|
|
principalId: null,
|
|
resourceType: 'skill',
|
|
resourceId: 'skill-id',
|
|
accessRoleId: 'skill_viewer',
|
|
grantedBy: 'system',
|
|
}),
|
|
).rejects.toThrow('Role skill_viewer not found');
|
|
expect(mockGrantPermission).not.toHaveBeenCalled();
|
|
});
|
|
});
|