Files
connectai/tests/skillInjectionService.test.ts
T

173 lines
6.6 KiB
TypeScript

/**
* Unit tests for FileSystemSkillInjectionService.
*
* Strategy: drive the service against a real temp directory so path-traversal
* defenses and writeFileSync paths are exercised end-to-end. The service
* accepts a fs override but the prod path uses node:fs — testing real fs
* gives stronger confidence here.
*/
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import {
FileSystemSkillInjectionService,
SkillInjectionError,
sanitizeSkillName,
} from '../src/skills/skillInjectionService';
function makeTempDir(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), 'astra-skill-test-'));
}
describe('sanitizeSkillName', () => {
test('keeps ASCII alphanumerics and -_.', () => {
expect(sanitizeSkillName('frontend_expert-v2.alpha')).toBe('frontend_expert-v2.alpha');
});
test('strips .md extension', () => {
expect(sanitizeSkillName('foo.md')).toBe('foo');
expect(sanitizeSkillName('foo.markdown')).toBe('foo');
});
test('replaces invalid chars with underscore', () => {
expect(sanitizeSkillName('hello world!')).toBe('hello_world');
expect(sanitizeSkillName('foo/bar')).toBe('foo_bar');
expect(sanitizeSkillName('파이썬')).toBe('');
});
test('strips leading/trailing dots and underscores', () => {
expect(sanitizeSkillName('___foo___')).toBe('foo');
expect(sanitizeSkillName('...foo...')).toBe('foo');
});
test('returns empty for path-traversal attempts', () => {
// ".." after stripping leading dots collapses to ""
expect(sanitizeSkillName('..')).toBe('');
expect(sanitizeSkillName('../etc/passwd')).toBe('etc_passwd');
});
test('caps length at 80', () => {
const long = 'a'.repeat(200);
expect(sanitizeSkillName(long).length).toBe(80);
});
test('rejects blank / non-string input', () => {
expect(sanitizeSkillName('')).toBe('');
expect(sanitizeSkillName(' ')).toBe('');
expect(sanitizeSkillName(undefined as any)).toBe('');
});
});
describe('FileSystemSkillInjectionService.inject', () => {
let dir: string;
let service: FileSystemSkillInjectionService;
beforeEach(() => {
dir = makeTempDir();
service = new FileSystemSkillInjectionService({
resolveSkillsDir: () => dir,
});
});
afterEach(() => {
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
});
test('writes <name>.md and <name>.meta.json', async () => {
const result = await service.inject({
name: 'react_expert',
content: '# React Expert\n\nKnow React deeply.',
displayName: 'React Expert',
description: 'React specialist agent.',
source: 'ezer',
});
expect(result.safeName).toBe('react_expert');
expect(fs.existsSync(result.filePath)).toBe(true);
expect(fs.existsSync(result.metaPath)).toBe(true);
const md = fs.readFileSync(result.filePath, 'utf8');
expect(md).toContain('# React Expert');
const meta = JSON.parse(fs.readFileSync(result.metaPath, 'utf8'));
expect(meta.name).toBe('react_expert');
expect(meta.displayName).toBe('React Expert');
expect(meta.description).toBe('React specialist agent.');
expect(meta.injectedFrom).toBe('ezer');
expect(meta.injectedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
});
test('sanitizes the name on disk', async () => {
const result = await service.inject({
name: 'hello world!.md',
content: 'body',
});
expect(result.safeName).toBe('hello_world');
expect(path.basename(result.filePath)).toBe('hello_world.md');
});
test('rejects empty name', async () => {
await expect(service.inject({ name: '', content: 'body' }))
.rejects.toMatchObject({ code: 'INVALID_NAME' });
});
test('rejects empty content', async () => {
await expect(service.inject({ name: 'foo', content: '' }))
.rejects.toMatchObject({ code: 'EMPTY_CONTENT' });
await expect(service.inject({ name: 'foo', content: ' ' }))
.rejects.toMatchObject({ code: 'EMPTY_CONTENT' });
});
test('rejects when skills dir cannot be resolved', async () => {
const noDirService = new FileSystemSkillInjectionService({
resolveSkillsDir: () => '',
});
await expect(noDirService.inject({ name: 'foo', content: 'body' }))
.rejects.toMatchObject({ code: 'NO_SKILLS_DIR' });
});
test('creates skills dir if it does not exist', async () => {
fs.rmSync(dir, { recursive: true, force: true });
expect(fs.existsSync(dir)).toBe(false);
const result = await service.inject({ name: 'foo', content: 'body' });
expect(fs.existsSync(result.filePath)).toBe(true);
});
test('falls back to safeName when displayName is blank', async () => {
const result = await service.inject({ name: 'bare', content: 'body' });
const meta = JSON.parse(fs.readFileSync(result.metaPath, 'utf8'));
expect(meta.displayName).toBe('bare');
expect(meta.injectedFrom).toBe('external');
});
test('overwrites existing skill on repeated inject', async () => {
await service.inject({ name: 'foo', content: 'v1' });
const r2 = await service.inject({ name: 'foo', content: 'v2' });
expect(fs.readFileSync(r2.filePath, 'utf8')).toBe('v2');
});
test('fires onInjected hook on success', async () => {
const calls: any[] = [];
const hooked = new FileSystemSkillInjectionService({
resolveSkillsDir: () => dir,
onInjected: (result, req) => calls.push({ result, req }),
});
await hooked.inject({ name: 'hooked', content: 'body', displayName: 'Hooked Skill' });
expect(calls).toHaveLength(1);
expect(calls[0].result.safeName).toBe('hooked');
expect(calls[0].req.displayName).toBe('Hooked Skill');
});
test('SkillInjectionError is thrown for write failures', async () => {
// Point at a path that exists but is not a directory — mkdirSync will
// throw. The service should wrap it as WRITE_FAILED.
const file = path.join(dir, 'notadir');
fs.writeFileSync(file, 'x');
const broken = new FileSystemSkillInjectionService({
resolveSkillsDir: () => path.join(file, 'sub'),
});
await expect(broken.inject({ name: 'foo', content: 'body' }))
.rejects.toMatchObject({ name: 'SkillInjectionError', code: 'WRITE_FAILED' });
});
});