| 1 | import importlib.util |
| 2 | import json |
| 3 | import sys |
| 4 | import types |
| 5 | import zipfile |
| 6 | from pathlib import Path |
| 7 | |
| 8 | import pytest |
| 9 | |
| 10 | |
| 11 | PROJECT_ROOT = Path(__file__).resolve().parents[1] |
| 12 | if str(PROJECT_ROOT) not in sys.path: |
| 13 | sys.path.insert(0, str(PROJECT_ROOT)) |
| 14 | |
| 15 | |
| 16 | def _load_skills_import_module(tmp_path): |
| 17 | module_name = "test_skills_import_module" |
| 18 | stub_names = ("helpers", "helpers.files", "helpers.skills") |
| 19 | missing = object() |
| 20 | originals = {name: sys.modules.get(name, missing) for name in stub_names} |
| 21 | |
| 22 | helpers_pkg = types.ModuleType("helpers") |
| 23 | helpers_pkg.__path__ = [] |
| 24 | |
| 25 | files = types.ModuleType("helpers.files") |
| 26 | files.get_abs_path = lambda *parts: str(tmp_path.joinpath(*parts)) |
| 27 | |
| 28 | skills = types.ModuleType("helpers.skills") |
| 29 | skills.discover_skill_md_files = lambda root: sorted(Path(root).rglob("SKILL.md")) |
| 30 | |
| 31 | helpers_pkg.files = files |
| 32 | helpers_pkg.skills = skills |
| 33 | sys.modules["helpers"] = helpers_pkg |
| 34 | sys.modules["helpers.files"] = files |
| 35 | sys.modules["helpers.skills"] = skills |
| 36 | |
| 37 | try: |
| 38 | spec = importlib.util.spec_from_file_location( |
| 39 | module_name, |
| 40 | PROJECT_ROOT / "helpers" / "skills_import.py", |
| 41 | ) |
| 42 | module = importlib.util.module_from_spec(spec) |
| 43 | assert spec and spec.loader |
| 44 | sys.modules[module_name] = module |
| 45 | spec.loader.exec_module(module) |
| 46 | return module |
| 47 | finally: |
| 48 | for name, original in originals.items(): |
| 49 | if original is missing: |
| 50 | sys.modules.pop(name, None) |
| 51 | else: |
| 52 | sys.modules[name] = original |
| 53 | |
| 54 | |
| 55 | def test_extract_skills_zip_rejects_path_traversal(monkeypatch, tmp_path): |
| 56 | skills_import = _load_skills_import_module(tmp_path) |
| 57 | |
| 58 | archive_path = tmp_path / "bad.zip" |
| 59 | with zipfile.ZipFile(archive_path, "w") as archive: |
| 60 | archive.writestr("../escape.txt", "nope") |
| 61 | |
| 62 | with pytest.raises(ValueError, match="Unsafe zip entry path"): |
| 63 | skills_import.extract_skills_zip(archive_path) |
| 64 | |
| 65 | assert not (tmp_path / "escape.txt").exists() |
| 66 | |
| 67 | |
| 68 | def test_extract_skills_zip_returns_single_top_level_root(monkeypatch, tmp_path): |
| 69 | skills_import = _load_skills_import_module(tmp_path) |
| 70 | |
| 71 | archive_path = tmp_path / "skills.zip" |
| 72 | with zipfile.ZipFile(archive_path, "w") as archive: |
| 73 | archive.writestr("pack/example/SKILL.md", "---\nname: Example\n---\nBody\n") |
| 74 | |
| 75 | source_root, cleanup_root = skills_import.extract_skills_zip(archive_path) |
| 76 | |
| 77 | assert source_root.name == "pack" |
| 78 | assert cleanup_root.is_dir() |
| 79 | assert (source_root / "example" / "SKILL.md").is_file() |
| 80 | |
| 81 | |
| 82 | def test_settings_skills_scan_section_and_prompt_assets_are_present(): |
| 83 | settings_store = ( |
| 84 | PROJECT_ROOT |
| 85 | / "webui" |
| 86 | / "components" |
| 87 | / "settings" |
| 88 | / "settings-store.js" |
| 89 | ).read_text(encoding="utf-8") |
| 90 | skills_settings = ( |
| 91 | PROJECT_ROOT |
| 92 | / "webui" |
| 93 | / "components" |
| 94 | / "settings" |
| 95 | / "skills" |
| 96 | / "skills-settings.html" |
| 97 | ).read_text(encoding="utf-8") |
| 98 | import_template = ( |
| 99 | PROJECT_ROOT |
| 100 | / "webui" |
| 101 | / "components" |
| 102 | / "settings" |
| 103 | / "skills" |
| 104 | / "import.html" |
| 105 | ).read_text(encoding="utf-8") |
| 106 | scan_prompt = ( |
| 107 | PROJECT_ROOT |
| 108 | / "webui" |
| 109 | / "components" |
| 110 | / "settings" |
| 111 | / "skills" |
| 112 | / "skill-scan-prompt.md" |
| 113 | ).read_text(encoding="utf-8") |
| 114 | scan_checks = json.loads( |
| 115 | ( |
| 116 | PROJECT_ROOT |
| 117 | / "webui" |
| 118 | / "components" |
| 119 | / "settings" |
| 120 | / "skills" |
| 121 | / "skill-scan-checks.json" |
| 122 | ).read_text(encoding="utf-8") |
| 123 | ) |
| 124 | scan_checks_text = json.dumps(scan_checks) |
| 125 | |
| 126 | assert "section-skills-scan" in settings_store |
| 127 | assert "settings/skills/scan.html" in skills_settings |
| 128 | assert "settings/skills/import.html" in skills_settings |
| 129 | assert settings_store.index("section-skills-import") < settings_store.index( |
| 130 | "section-skills-scan" |
| 131 | ) |
| 132 | assert skills_settings.index("section-skills-import") < skills_settings.index( |
| 133 | "section-skills-scan" |
| 134 | ) |
| 135 | assert "scanSelectedFile()" in import_template |
| 136 | assert "snyk-agent-scan@latest --json --no-bootstrap" in scan_prompt |
| 137 | assert "E004" in scan_checks_text |
| 138 | assert "W007" in scan_checks_text |
| 139 | assert "W008" in scan_checks_text |
| 140 | assert "W011" in scan_checks_text |
| 141 | assert "W012" in scan_checks_text |
| 142 | |
| 143 | |
| 144 | def test_list_and_import_skills_filters_are_project_only(): |
| 145 | list_template = ( |
| 146 | PROJECT_ROOT |
| 147 | / "webui" |
| 148 | / "components" |
| 149 | / "settings" |
| 150 | / "skills" |
| 151 | / "list.html" |
| 152 | ).read_text(encoding="utf-8") |
| 153 | list_store = ( |
| 154 | PROJECT_ROOT |
| 155 | / "webui" |
| 156 | / "components" |
| 157 | / "settings" |
| 158 | / "skills" |
| 159 | / "skills-list-store.js" |
| 160 | ).read_text(encoding="utf-8") |
| 161 | import_template = ( |
| 162 | PROJECT_ROOT |
| 163 | / "webui" |
| 164 | / "components" |
| 165 | / "settings" |
| 166 | / "skills" |
| 167 | / "import.html" |
| 168 | ).read_text(encoding="utf-8") |
| 169 | import_store = ( |
| 170 | PROJECT_ROOT |
| 171 | / "webui" |
| 172 | / "components" |
| 173 | / "settings" |
| 174 | / "skills" |
| 175 | / "skills-import-store.js" |
| 176 | ).read_text(encoding="utf-8") |
| 177 | |
| 178 | assert "Agent profile" not in list_template |
| 179 | assert "agentProfile" not in list_store |
| 180 | assert "agent_profile" not in list_store |
| 181 | assert "Project" in list_template |
| 182 | assert "project_name" in list_store |
| 183 | assert "Limit to agent profile" not in import_template |
| 184 | assert "agentProfile" not in import_store |
| 185 | assert "agent_profile" not in import_store |
| 186 | assert "Limit to project" in import_template |
| 187 | assert "project_name" in import_store |
| 188 | |
| 189 | |
| 190 | def test_list_skills_has_mcp_style_search(): |
| 191 | list_template = ( |
| 192 | PROJECT_ROOT |
| 193 | / "webui" |
| 194 | / "components" |
| 195 | / "settings" |
| 196 | / "skills" |
| 197 | / "list.html" |
| 198 | ).read_text(encoding="utf-8") |
| 199 | list_store = ( |
| 200 | PROJECT_ROOT |
| 201 | / "webui" |
| 202 | / "components" |
| 203 | / "settings" |
| 204 | / "skills" |
| 205 | / "skills-list-store.js" |
| 206 | ).read_text(encoding="utf-8") |
| 207 | |
| 208 | assert 'type="search"' in list_template |
| 209 | assert "placeholder=\"Search skills\"" in list_template |
| 210 | assert "skills-search-clear" in list_template |
| 211 | assert "filteredSkills" in list_template |
| 212 | assert "No skills match this search." in list_template |
| 213 | assert "skillSearch" in list_store |
| 214 | assert "matchesSearchQuery" in list_store |
| 215 | assert "clearSkillSearch()" in list_store |