173 lines
6.6 KiB
TypeScript
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' });
|
|
});
|
|
});
|