/** * 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 .md and .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' }); }); });