Files
LangBot/tests/unit_tests/test_skill_repository.py
T
2026-09-05 23:09:46 +08:00

202 lines
6.5 KiB
Python

from types import SimpleNamespace
import pytest
from langbot.pkg.api.http.context import ExecutionContext
from langbot.pkg.skill.repository import SkillRepository, SkillRevisionMismatchError
_CONTEXT = ExecutionContext(
instance_uuid='instance-a',
workspace_uuid='workspace-a',
placement_generation=1,
)
async def _binding(workspace_uuid, *, expected_generation):
return SimpleNamespace(
instance_uuid=_CONTEXT.instance_uuid,
workspace_uuid=workspace_uuid,
placement_generation=expected_generation,
)
def _repository(tmp_path) -> SkillRepository:
app = SimpleNamespace(
workspace_service=SimpleNamespace(
get_execution_binding=_binding,
),
instance_config=SimpleNamespace(
data={
'skills': {'root': str(tmp_path / 'skill-store')},
'box': {
'enabled': False,
'local': {
'host_root': str(tmp_path / 'box'),
},
}
}
),
)
return SkillRepository(app)
def test_repository_prefers_standalone_skill_root(tmp_path):
repository = _repository(tmp_path)
assert repository._store.root == str((tmp_path / 'skill-store').resolve())
def test_repository_keeps_old_box_root_only_for_online_upgrade(tmp_path):
app = SimpleNamespace(
workspace_service=SimpleNamespace(get_execution_binding=_binding),
instance_config=SimpleNamespace(
data={
'box': {
'local': {
'host_root': str(tmp_path / 'box'),
'skills_root': 'legacy-skills',
}
}
}
),
)
repository = SkillRepository(app)
assert repository._store.root == str((tmp_path / 'box' / 'legacy-skills').resolve())
@pytest.mark.asyncio
async def test_repository_crud_and_reads_do_not_require_box(tmp_path):
repository = _repository(tmp_path)
await repository.create_skill(
_CONTEXT,
{
'name': 'docs-only',
'display_name': 'Docs only',
'description': 'Read-only guidance',
'instructions': 'Read references/guide.md.',
},
)
await repository.write_skill_file(
_CONTEXT,
'docs-only',
'references/guide.md',
'# Guide\n\nNo execution needed.',
)
skill = await repository.get_skill(_CONTEXT, 'docs-only', snapshot=True)
assert skill is not None
assert skill['revision'].startswith('sha256:')
assert [item['name'] for item in await repository.list_skills(_CONTEXT)] == ['docs-only']
listed = await repository.list_skill_resources(
_CONTEXT,
'docs-only',
'references',
expected_revision=skill['revision'],
)
assert listed['entries'][0]['path'] == 'references/guide.md'
assert listed['entries'][0]['mime_type'] == 'text/markdown'
resource = await repository.read_skill_resource(
_CONTEXT,
'docs-only',
'references/guide.md',
expected_revision=skill['revision'],
)
assert resource['content'].startswith('# Guide')
assert resource['revision'] == skill['revision']
@pytest.mark.asyncio
async def test_repository_rejects_traversal_and_stale_revision(tmp_path):
repository = _repository(tmp_path)
await repository.create_skill(
_CONTEXT,
{'name': 'safe', 'description': 'Safe', 'instructions': 'Use the reference.'},
)
await repository.write_skill_file(_CONTEXT, 'safe', 'reference.md', 'first')
skill = await repository.get_skill(_CONTEXT, 'safe', snapshot=True)
assert skill is not None
with pytest.raises(ValueError, match='stay within'):
await repository.read_skill_resource(_CONTEXT, 'safe', '../secret.txt')
await repository.write_skill_file(_CONTEXT, 'safe', 'reference.md', 'second')
with pytest.raises(SkillRevisionMismatchError, match='reactivate'):
await repository.read_skill_resource(
_CONTEXT,
'safe',
'reference.md',
expected_revision=skill['revision'],
)
@pytest.mark.asyncio
async def test_repository_scopes_skills_by_workspace(tmp_path):
repository = _repository(tmp_path)
other_context = ExecutionContext(
instance_uuid='instance-a',
workspace_uuid='workspace-b',
placement_generation=1,
)
await repository.create_skill(_CONTEXT, {'name': 'private', 'instructions': 'A'})
assert [skill['name'] for skill in await repository.list_skills(_CONTEXT)] == ['private']
assert await repository.list_skills(other_context) == []
@pytest.mark.asyncio
async def test_repository_rejects_stale_workspace_placement(tmp_path):
repository = _repository(tmp_path)
async def stale_binding(workspace_uuid, *, expected_generation):
return SimpleNamespace(
instance_uuid=_CONTEXT.instance_uuid,
workspace_uuid=workspace_uuid,
placement_generation=expected_generation + 1,
)
repository.ap.workspace_service.get_execution_binding = stale_binding
with pytest.raises(ValueError, match='stale Workspace placement'):
await repository.list_skills(_CONTEXT)
@pytest.mark.asyncio
async def test_repository_imports_only_from_the_fenced_workspace(tmp_path):
repository = _repository(tmp_path)
namespace = repository._namespace(_CONTEXT)
source = tmp_path / 'box' / 'default' / 'tenants' / namespace / 'draft'
source.mkdir(parents=True)
(source / 'SKILL.md').write_text(
'---\nname: draft\ndescription: Draft skill\n---\n\nFollow the guide.',
encoding='utf-8',
)
(source / 'guide.md').write_text('Imported resource', encoding='utf-8')
scanned = await repository.scan_skill_directory(_CONTEXT, str(source))
imported = await repository.import_skill_directory(
_CONTEXT,
str(source),
{
'name': scanned['name'],
'display_name': scanned['display_name'],
'description': scanned['description'],
'instructions': scanned['instructions'],
},
)
assert imported['name'] == 'draft'
resource = await repository.read_skill_file(_CONTEXT, 'draft', 'guide.md')
assert resource['content'] == 'Imported resource'
outside = tmp_path / 'outside'
outside.mkdir()
(outside / 'SKILL.md').write_text('Outside', encoding='utf-8')
with pytest.raises(ValueError, match='trusted source root'):
await repository.scan_skill_directory(_CONTEXT, str(outside))