const express = require('express'); const request = require('supertest'); const JSZip = require('jszip'); const mongoose = require('mongoose'); const { MongoMemoryServer } = require('mongodb-memory-server'); jest.mock('librechat-data-provider', () => { const actual = jest.requireActual('librechat-data-provider'); return { ...actual, mergeFileConfig: jest.fn((dynamic) => { const skillFileSizeLimit = dynamic?.skills?.fileSizeLimit; return { ...actual.fileConfig, ...dynamic, skills: { ...(actual.fileConfig.skills ?? { fileSizeLimit: 50 * 1024 * 1024 }), ...(skillFileSizeLimit !== undefined ? { fileSizeLimit: skillFileSizeLimit * 1024 * 1024 } : {}), }, }; }), }; }); const { SystemRoles, ResourceType, AccessRoleIds, PrincipalType, PermissionBits, } = require('librechat-data-provider'); let mockFileConfig; const mockMaybeRunGitHubSkillSyncForRequest = jest.fn(async () => false); jest.mock('~/server/services/Config', () => ({ getCachedTools: jest.fn().mockResolvedValue({}), getAppConfig: jest.fn().mockResolvedValue({ fileStrategy: 'local', paths: { uploads: '/tmp/uploads', images: '/tmp/images' }, }), })); jest.mock('~/server/middleware/config/app', () => (req, _res, next) => { req.config = { fileStrategy: 'local', paths: { uploads: '/tmp/uploads', images: '/tmp/images' }, fileConfig: mockFileConfig, }; next(); }); jest.mock('~/server/services/Files/strategies', () => ({ getStrategyFunctions: jest.fn().mockReturnValue({ saveBuffer: jest.fn().mockResolvedValue('/uploads/test/file.txt'), getDownloadStream: jest.fn().mockResolvedValue({ pipe: jest.fn(), on: jest.fn(), [Symbol.asyncIterator]: async function* () { yield Buffer.from('test content'); }, }), }), })); jest.mock('~/server/utils/getFileStrategy', () => ({ getFileStrategy: jest.fn().mockReturnValue('local'), })); jest.mock('~/server/services/Skills/sync', () => ({ maybeRunGitHubSkillSyncForRequest: mockMaybeRunGitHubSkillSyncForRequest, })); jest.mock('~/models', () => { const mongoose = require('mongoose'); const { createMethods } = require('@librechat/data-schemas'); const methods = createMethods(mongoose, { removeAllPermissions: async ({ resourceType, resourceId }) => { const AclEntry = mongoose.models.AclEntry; if (AclEntry) { await AclEntry.deleteMany({ resourceType, resourceId }); } }, }); // Override getRoleByName to return a permissive SKILLS capability block for all // test users. The real role seeding relies on `initializeRoles` which this // suite intentionally skips to keep setup minimal. return { ...methods, getRoleByName: jest.fn(), }; }); jest.mock('~/server/middleware', () => ({ requireJwtAuth: (req, res, next) => next(), canAccessSkillResource: jest.requireActual('~/server/middleware').canAccessSkillResource, })); let app; let mongoServer; let skillRoutes; let Skill; let SkillFile; let AclEntry; let AccessRole; let User; let testUsers; let testRoles; let grantPermission; let currentTestUser; function setTestUser(user) { currentTestUser = user; } beforeAll(async () => { mongoServer = await MongoMemoryServer.create(); await mongoose.connect(mongoServer.getUri()); const dbModels = require('~/db/models'); Skill = dbModels.Skill; SkillFile = dbModels.SkillFile; AclEntry = dbModels.AclEntry; AccessRole = dbModels.AccessRole; User = dbModels.User; const permissionService = require('~/server/services/PermissionService'); grantPermission = permissionService.grantPermission; await setupTestData(); app = express(); app.use(express.json()); app.use((req, res, next) => { if (currentTestUser) { req.user = { ...(currentTestUser.toObject ? currentTestUser.toObject() : currentTestUser), id: currentTestUser._id.toString(), _id: currentTestUser._id, name: currentTestUser.name, role: currentTestUser.role, }; } next(); }); currentTestUser = testUsers.owner; skillRoutes = require('./skills'); app.use('/api/skills', skillRoutes); }); afterEach(async () => { await Skill.deleteMany({}); await SkillFile.deleteMany({}); await AclEntry.deleteMany({}); currentTestUser = testUsers.owner; mockFileConfig = undefined; mockMaybeRunGitHubSkillSyncForRequest.mockClear(); }); afterAll(async () => { await mongoose.disconnect(); await mongoServer.stop(); jest.clearAllMocks(); }); async function setupTestData() { testRoles = { viewer: await AccessRole.create({ accessRoleId: AccessRoleIds.SKILL_VIEWER, name: 'Viewer', resourceType: ResourceType.SKILL, permBits: PermissionBits.VIEW, }), editor: await AccessRole.create({ accessRoleId: AccessRoleIds.SKILL_EDITOR, name: 'Editor', resourceType: ResourceType.SKILL, permBits: PermissionBits.VIEW | PermissionBits.EDIT, }), owner: await AccessRole.create({ accessRoleId: AccessRoleIds.SKILL_OWNER, name: 'Owner', resourceType: ResourceType.SKILL, permBits: PermissionBits.VIEW | PermissionBits.EDIT | PermissionBits.DELETE | PermissionBits.SHARE, }), }; testUsers = { owner: await User.create({ name: 'Skill Owner', email: 'skill-owner@test.com', role: SystemRoles.USER, }), editor: await User.create({ name: 'Skill Editor', email: 'skill-editor@test.com', role: SystemRoles.USER, }), noAccess: await User.create({ name: 'No Access', email: 'no-access@test.com', role: SystemRoles.USER, }), }; const { getRoleByName } = require('~/models'); getRoleByName.mockImplementation((roleName) => { if (roleName === SystemRoles.USER || roleName === SystemRoles.ADMIN) { return { permissions: { SKILLS: { USE: true, CREATE: true, SHARE: true, SHARE_PUBLIC: true, }, }, }; } return null; }); } async function createSkillAsOwner(overrides = {}) { // Description is deliberately kept above the 20-char short-description // warning threshold so existing tests don't trip the coaching warning. const res = await request(app) .post('/api/skills') .send({ name: 'demo-skill', description: 'A small demo skill used in routing integration tests.', body: '# Demo', ...overrides, }); return res; } describe('Skill routes', () => { let errSpy; beforeEach(() => { errSpy = jest.spyOn(console, 'error').mockImplementation(); }); afterEach(() => errSpy.mockRestore()); describe('POST /api/skills', () => { it('creates a skill and grants SKILL_OWNER ACL', async () => { const res = await createSkillAsOwner(); expect(res.status).toBe(201); expect(res.body._id).toBeDefined(); expect(res.body.version).toBe(1); expect(res.body.name).toBe('demo-skill'); // No warnings on a description that comfortably clears the threshold. expect(res.body.warnings).toBeUndefined(); const acl = await AclEntry.findOne({ resourceType: ResourceType.SKILL, resourceId: res.body._id, principalType: PrincipalType.USER, principalId: testUsers.owner._id, }); expect(acl).toBeTruthy(); expect(acl.roleId.toString()).toBe(testRoles.owner._id.toString()); }); it('attaches a TOO_SHORT warning on create when description is under 20 chars', async () => { const res = await createSkillAsOwner({ name: 'short-desc-skill', description: 'Too short.', }); expect(res.status).toBe(201); expect(res.body._id).toBeDefined(); expect(Array.isArray(res.body.warnings)).toBe(true); expect(res.body.warnings).toEqual([ expect.objectContaining({ field: 'description', code: 'TOO_SHORT', severity: 'warning', }), ]); }); it('rejects names starting with reserved brand prefixes', async () => { const anthropic = await createSkillAsOwner({ name: 'anthropic-helper' }); expect(anthropic.status).toBe(400); const claude = await createSkillAsOwner({ name: 'claude-helper' }); expect(claude.status).toBe(400); }); it('allows names that merely contain reserved brand words as substrings', async () => { const res = await createSkillAsOwner({ name: 'research-anthropic-helper' }); expect(res.status).toBe(201); }); it('rejects reserved CLI command names', async () => { const res = await createSkillAsOwner({ name: 'settings' }); expect(res.status).toBe(400); }); it('rejects frontmatter with unknown keys', async () => { const res = await createSkillAsOwner({ name: 'bad-frontmatter-skill', frontmatter: { 'not-a-real-key': 'value' }, }); expect(res.status).toBe(400); expect(res.body.issues).toEqual( expect.arrayContaining([expect.objectContaining({ code: 'UNKNOWN_KEY' })]), ); }); it('rejects missing description with 400', async () => { const res = await request(app).post('/api/skills').send({ name: 'x-skill', body: '' }); expect(res.status).toBe(400); }); it('rejects invalid name with 400 validation failure', async () => { const res = await createSkillAsOwner({ name: 'BAD NAME' }); expect(res.status).toBe(400); expect(res.body.issues).toBeDefined(); }); it('rejects duplicate names with 409', async () => { const a = await createSkillAsOwner(); expect(a.status).toBe(201); const b = await createSkillAsOwner(); expect(b.status).toBe(409); }); }); describe('POST /api/skills/import', () => { it('enforces fileConfig.skills.fileSizeLimit before import handling', async () => { mockFileConfig = { skills: { fileSizeLimit: 1, }, }; const res = await request(app) .post('/api/skills/import') .attach('file', Buffer.alloc(2 * 1024 * 1024), { filename: 'too-large.skill', contentType: 'application/zip', }); const { mergeFileConfig } = require('librechat-data-provider'); expect(mergeFileConfig).toHaveBeenCalledWith(mockFileConfig); expect(res.status).toBe(400); expect(res.body.error).toMatch(/file too large/i); }); it('persists storage metadata for imported skill files', async () => { const savedFilepath = 'https://cdn.example.com/r/us-east-2/uploads/user123/imported-script.sh'; const saveBuffer = jest.fn().mockResolvedValue(savedFilepath); const { getFileStrategy } = require('~/server/utils/getFileStrategy'); const { getStrategyFunctions } = require('~/server/services/Files/strategies'); getFileStrategy.mockReturnValueOnce('cloudfront'); getStrategyFunctions.mockReturnValueOnce({ saveBuffer }); const zip = new JSZip(); zip.file( 'SKILL.md', [ '---', 'name: imported-skill', 'description: Imported skill description for route tests.', '---', '# Imported Skill', ].join('\n'), ); zip.file('scripts/imported-script.sh', 'echo imported'); const buffer = await zip.generateAsync({ type: 'nodebuffer' }); const res = await request(app).post('/api/skills/import').attach('file', buffer, { filename: 'imported-skill.skill', contentType: 'application/zip', }); expect(res.status).toBe(201); expect(saveBuffer).toHaveBeenCalledWith( expect.objectContaining({ userId: testUsers.owner._id.toString(), basePath: 'uploads', }), ); const savedFile = await SkillFile.findOne({ relativePath: 'scripts/imported-script.sh', }).lean(); expect(savedFile).toEqual( expect.objectContaining({ filepath: savedFilepath, source: 'cloudfront', storageKey: 'r/us-east-2/uploads/user123/imported-script.sh', storageRegion: 'us-east-2', }), ); }); }); describe('GET /api/skills', () => { it('returns only skills the caller can access', async () => { const mine = await createSkillAsOwner({ name: 'mine-skill' }); expect(mine.status).toBe(201); setTestUser(testUsers.noAccess); const other = await createSkillAsOwner({ name: 'other-skill' }); expect(other.status).toBe(201); // Note: the user middleware grants owner perms to whichever user created, so both // users see their own skill only. setTestUser(testUsers.owner); const res = await request(app).get('/api/skills'); expect(res.status).toBe(200); expect(mockMaybeRunGitHubSkillSyncForRequest).toHaveBeenCalledWith( expect.objectContaining({ config: expect.objectContaining({ fileStrategy: 'local' }), user: expect.objectContaining({ id: testUsers.owner._id.toString() }), }), ); expect(res.body.skills.length).toBe(1); expect(res.body.skills[0].name).toBe('mine-skill'); }); }); describe('GET /api/skills/:id', () => { it('returns 403 when the user has no access', async () => { const created = await createSkillAsOwner(); expect(created.status).toBe(201); setTestUser(testUsers.noAccess); const res = await request(app).get(`/api/skills/${created.body._id}`); expect(res.status).toBe(403); }); it('returns the skill to the owner with isPublic flag', async () => { const created = await createSkillAsOwner(); const res = await request(app).get(`/api/skills/${created.body._id}`); expect(res.status).toBe(200); expect(res.body.name).toBe('demo-skill'); expect(res.body.isPublic).toBe(false); }); }); describe('PATCH /api/skills/:id (optimistic concurrency)', () => { it('updates with correct expectedVersion and bumps version', async () => { const created = await createSkillAsOwner(); const res = await request(app) .patch(`/api/skills/${created.body._id}`) .send({ expectedVersion: 1, description: 'Updated description' }); expect(res.status).toBe(200); expect(res.body.version).toBe(2); expect(res.body.description).toBe('Updated description'); }); it('returns 409 on stale expectedVersion', async () => { const created = await createSkillAsOwner(); const first = await request(app) .patch(`/api/skills/${created.body._id}`) .send({ expectedVersion: 1, description: 'First' }); expect(first.status).toBe(200); const stale = await request(app) .patch(`/api/skills/${created.body._id}`) .send({ expectedVersion: 1, description: 'Stale' }); expect(stale.status).toBe(409); expect(stale.body.error).toBe('skill_version_conflict'); expect(stale.body.current.version).toBe(2); }); it('rejects updates without expectedVersion', async () => { const created = await createSkillAsOwner(); const res = await request(app) .patch(`/api/skills/${created.body._id}`) .send({ description: 'no version' }); expect(res.status).toBe(400); }); it('returns 403 for a user without EDIT permission', async () => { const created = await createSkillAsOwner(); setTestUser(testUsers.noAccess); const res = await request(app) .patch(`/api/skills/${created.body._id}`) .send({ expectedVersion: 1, description: 'nope' }); expect(res.status).toBe(403); }); }); describe('DELETE /api/skills/:id', () => { it('deletes and cascades ACL entries', async () => { const created = await createSkillAsOwner(); const res = await request(app).delete(`/api/skills/${created.body._id}`); expect(res.status).toBe(200); expect(res.body.deleted).toBe(true); const remainingAcl = await AclEntry.countDocuments({ resourceType: ResourceType.SKILL, resourceId: created.body._id, }); expect(remainingAcl).toBe(0); }); it('returns 403 for a non-owner', async () => { const created = await createSkillAsOwner(); setTestUser(testUsers.noAccess); const res = await request(app).delete(`/api/skills/${created.body._id}`); expect(res.status).toBe(403); }); }); describe('GET /api/skills/:id/files', () => { it('returns an empty list for a skill with no files', async () => { const created = await createSkillAsOwner(); const res = await request(app).get(`/api/skills/${created.body._id}/files`); expect(res.status).toBe(200); expect(res.body.files).toEqual([]); }); }); describe('POST /api/skills/:id/files (live)', () => { it('returns 400 when no file is provided', async () => { const created = await createSkillAsOwner(); const res = await request(app).post(`/api/skills/${created.body._id}/files`); expect(res.status).toBe(400); expect(res.body.error).toMatch(/no file/i); }); }); describe('GET /api/skills/:id/files/:relativePath', () => { it('returns SKILL.md content from skill body', async () => { const created = await createSkillAsOwner(); const res = await request(app).get(`/api/skills/${created.body._id}/files/SKILL.md`); expect(res.status).toBe(200); expect(res.body.mimeType).toBe('text/markdown'); expect(res.body.isBinary).toBe(false); expect(res.body.filename).toBe('SKILL.md'); expect(res.body.content).toBeDefined(); }); it('returns 404 for a nonexistent file', async () => { const created = await createSkillAsOwner(); const res = await request(app).get( `/api/skills/${created.body._id}/files/scripts%2Fmissing.sh`, ); expect(res.status).toBe(404); }); }); describe('DELETE /api/skills/:id/files/:relativePath', () => { const { upsertSkillFile } = require('~/models'); it('deletes an existing skill file, bumps skill version, and returns 200', async () => { const created = await createSkillAsOwner(); await upsertSkillFile({ skillId: created.body._id, relativePath: 'scripts/parse.sh', file_id: 'file-1', filename: 'parse.sh', filepath: '/tmp/parse.sh', source: 'local', mimeType: 'text/x-shellscript', bytes: 42, author: testUsers.owner._id, }); const beforeSkill = await request(app).get(`/api/skills/${created.body._id}`); expect(beforeSkill.body.fileCount).toBe(1); expect(beforeSkill.body.version).toBe(2); const res = await request(app).delete( `/api/skills/${created.body._id}/files/scripts%2Fparse.sh`, ); expect(res.status).toBe(200); expect(res.body).toEqual({ skillId: created.body._id, relativePath: 'scripts/parse.sh', deleted: true, }); const afterSkill = await request(app).get(`/api/skills/${created.body._id}`); expect(afterSkill.body.fileCount).toBe(0); expect(afterSkill.body.version).toBe(3); }); it('returns 404 when the file does not exist', async () => { const created = await createSkillAsOwner(); const res = await request(app).delete( `/api/skills/${created.body._id}/files/scripts%2Fmissing.sh`, ); expect(res.status).toBe(404); }); it('returns 403 for a non-owner', async () => { const created = await createSkillAsOwner(); setTestUser(testUsers.noAccess); const res = await request(app).delete( `/api/skills/${created.body._id}/files/scripts%2Fparse.sh`, ); expect(res.status).toBe(403); }); }); describe('Sharing via ACL (editor grant)', () => { it('allows an editor to patch a shared skill', async () => { const created = await createSkillAsOwner(); await grantPermission({ principalType: PrincipalType.USER, principalId: testUsers.editor._id, resourceType: ResourceType.SKILL, resourceId: created.body._id, accessRoleId: AccessRoleIds.SKILL_EDITOR, grantedBy: testUsers.owner._id, }); setTestUser(testUsers.editor); const res = await request(app) .patch(`/api/skills/${created.body._id}`) .send({ expectedVersion: 1, description: 'Edited by editor' }); expect(res.status).toBe(200); // Editor should NOT be able to delete const del = await request(app).delete(`/api/skills/${created.body._id}`); expect(del.status).toBe(403); }); }); });