"""Tests for the ObsiGate plugin system (backend/plugins.py).""" import json import pytest from backend.plugins import ( ALLOWED_HOOKS, ALLOWED_PERMISSIONS, MANIFEST_FILENAME, MAX_PLUGINS_PER_VAULT, PLUGINS_DIR_NAME, PluginManager, PluginManifest, PluginRegistry, _validate_plugin_directory, _validate_plugin_zip, ) # ═══════════════════════════════════════════════════════════════════════════════ # Fixtures # ═══════════════════════════════════════════════════════════════════════════════ @pytest.fixture def valid_manifest(): """A valid plugin manifest dict.""" return { "name": "test-plugin", "version": "1.0.0", "description": "A test plugin", "author": "test-author", "main": "index.js", "hooks": { "onFileRender": "renderFile", "onSearchFilter": "filterSearch", }, "permissions": ["read_files"], "min_obsigate_version": "2.1.0", "license": "MIT", } @pytest.fixture def minimal_manifest(): """Minimal valid manifest (only required fields).""" return { "name": "minimal", "version": "0.1.0", "description": "Minimal plugin", "author": "author", "main": "main.js", } @pytest.fixture def temp_vault(tmp_path): """Create a temporary vault directory.""" vault = tmp_path / "test-vault" vault.mkdir() return vault @pytest.fixture def plugins_dir(temp_vault): """Create the plugins directory inside a temp vault.""" pd = temp_vault / PLUGINS_DIR_NAME pd.mkdir() return pd @pytest.fixture def installed_plugin(plugins_dir, valid_manifest): """A plugin already installed on disk.""" plugin_dir = plugins_dir / "test-plugin" plugin_dir.mkdir() (plugin_dir / MANIFEST_FILENAME).write_text(json.dumps(valid_manifest)) (plugin_dir / "index.js").write_text("export function renderFile(ctx) { return ctx; }") return plugin_dir # ═══════════════════════════════════════════════════════════════════════════════ # B5 — Manifest Validation # ═══════════════════════════════════════════════════════════════════════════════ class TestManifestValidation: """Test PluginManifest validation logic.""" def test_valid_manifest_parses(self, valid_manifest): m = PluginManifest.from_dict(valid_manifest) assert m.name == "test-plugin" assert m.version == "1.0.0" assert m.main == "index.js" assert "onFileRender" in m.hooks assert "onSearchFilter" in m.hooks assert m.permissions == ["read_files"] def test_minimal_manifest(self, minimal_manifest): m = PluginManifest.from_dict(minimal_manifest) assert m.name == "minimal" assert m.hooks == {} assert m.permissions == [] def test_missing_required_field(self): data = {"name": "x", "version": "1.0.0", "description": "d", "author": "a"} with pytest.raises(ValueError, match="Missing required field: main"): PluginManifest.from_dict(data) def test_invalid_name_uppercase(self, valid_manifest): valid_manifest["name"] = "MyPlugin" with pytest.raises(ValueError, match="lowercase alphanumeric"): PluginManifest.from_dict(valid_manifest) def test_invalid_name_special_chars(self, valid_manifest): valid_manifest["name"] = "my_plugin!" with pytest.raises(ValueError, match="lowercase alphanumeric"): PluginManifest.from_dict(valid_manifest) def test_invalid_name_single_hyphen(self, valid_manifest): valid_manifest["name"] = "-bad-" with pytest.raises(ValueError, match="lowercase alphanumeric"): PluginManifest.from_dict(valid_manifest) def test_valid_single_char_name(self): d = {"name": "a", "version": "1.0.0", "description": "d", "author": "a", "main": "a.js"} m = PluginManifest.from_dict(d) assert m.name == "a" def test_invalid_version_no_patch(self, valid_manifest): valid_manifest["version"] = "1.0" with pytest.raises(ValueError, match="semantic version"): PluginManifest.from_dict(valid_manifest) def test_invalid_version_letters(self, valid_manifest): valid_manifest["version"] = "1.0.0-beta.1" # Should work — pre-release tags are allowed m = PluginManifest.from_dict(valid_manifest) assert m.version == "1.0.0-beta.1" def test_invalid_version_not_semver(self, valid_manifest): valid_manifest["version"] = "v1.0.0" with pytest.raises(ValueError, match="semantic version"): PluginManifest.from_dict(valid_manifest) def test_unknown_hook(self, valid_manifest): valid_manifest["hooks"]["onMagic"] = "handleMagic" with pytest.raises(ValueError, match="Unknown hook"): PluginManifest.from_dict(valid_manifest) def test_unknown_permission(self, valid_manifest): valid_manifest["permissions"] = ["evil_access"] with pytest.raises(ValueError, match="Unknown permission"): PluginManifest.from_dict(valid_manifest) def test_all_allowed_hooks(self): hooks = {h: f"handle_{h}" for h in ALLOWED_HOOKS} d = {"name": "full-hooks", "version": "1.0.0", "description": "d", "author": "a", "main": "x.js", "hooks": hooks} m = PluginManifest.from_dict(d) assert len(m.hooks) == len(ALLOWED_HOOKS) def test_all_allowed_permissions(self): d = {"name": "full-perms", "version": "1.0.0", "description": "d", "author": "a", "main": "x.js", "permissions": list(ALLOWED_PERMISSIONS)} m = PluginManifest.from_dict(d) assert len(m.permissions) == len(ALLOWED_PERMISSIONS) def test_empty_name_rejected(self): d = {"name": "", "version": "1.0.0", "description": "d", "author": "a", "main": "x.js"} with pytest.raises(ValueError, match="Missing required field: name"): PluginManifest.from_dict(d) def test_empty_version_rejected(self): d = {"name": "ok", "version": "", "description": "d", "author": "a", "main": "x.js"} with pytest.raises(ValueError, match="Missing required field: version"): PluginManifest.from_dict(d) # ═══════════════════════════════════════════════════════════════════════════════ # B6 — Plugin Manager (install / uninstall / enable / disable) # ═══════════════════════════════════════════════════════════════════════════════ class TestPluginManager: """Test PluginManager installation and lifecycle.""" def test_install_plugin_from_dict(self, plugins_dir, valid_manifest): manager = PluginManager(plugins_dir) result = manager.install(valid_manifest, "export function renderFile() {}") assert result["name"] == "test-plugin" assert result["version"] == "1.0.0" assert result["enabled"] is True assert (plugins_dir / "test-plugin" / MANIFEST_FILENAME).exists() assert (plugins_dir / "test-plugin" / "index.js").exists() def test_install_duplicate_rejected(self, plugins_dir, valid_manifest): manager = PluginManager(plugins_dir) manager.install(valid_manifest, "code") with pytest.raises(ValueError, match="already installed"): manager.install(valid_manifest, "code2") def test_uninstall_plugin(self, plugins_dir, installed_plugin): manager = PluginManager(plugins_dir) manager.uninstall("test-plugin") assert not (plugins_dir / "test-plugin").exists() def test_uninstall_nonexistent(self, plugins_dir): manager = PluginManager(plugins_dir) with pytest.raises(ValueError, match="not found"): manager.uninstall("no-such-plugin") def test_enable_disable(self, plugins_dir, installed_plugin): manager = PluginManager(plugins_dir) manager.disable("test-plugin") assert manager.is_disabled("test-plugin") manager.enable("test-plugin") assert not manager.is_disabled("test-plugin") def test_list_plugins(self, plugins_dir, installed_plugin): manager = PluginManager(plugins_dir) plugins = manager.list_plugins() assert len(plugins) == 1 assert plugins[0]["name"] == "test-plugin" assert plugins[0]["enabled"] is True def test_get_plugin(self, plugins_dir, installed_plugin): manager = PluginManager(plugins_dir) plugin = manager.get_plugin("test-plugin") assert plugin is not None assert plugin["manifest"]["name"] == "test-plugin" def test_get_nonexistent(self, plugins_dir): manager = PluginManager(plugins_dir) assert manager.get_plugin("nope") is None def test_max_plugins_limit(self, plugins_dir): manager = PluginManager(plugins_dir) for i in range(MAX_PLUGINS_PER_VAULT): m = {"name": f"plug-{i}", "version": "1.0.0", "description": "d", "author": "a", "main": "x.js"} manager.install(m, "code") # Next one should fail m = {"name": "plug-extra", "version": "1.0.0", "description": "d", "author": "a", "main": "x.js"} with pytest.raises(ValueError, match="Maximum"): manager.install(m, "code") def test_disabled_marker_file(self, plugins_dir, installed_plugin): manager = PluginManager(plugins_dir) manager.disable("test-plugin") marker = plugins_dir / "test-plugin" / ".disabled" assert marker.exists() manager.enable("test-plugin") assert not marker.exists() # ═══════════════════════════════════════════════════════════════════════════════ # B7 — Plugin Registry (enabled plugins by vault) # ═══════════════════════════════════════════════════════════════════════════════ class TestPluginRegistry: """Test PluginRegistry aggregation across vaults.""" def test_get_enabled_plugins(self, plugins_dir, installed_plugin): registry = PluginRegistry() plugins = registry.get_enabled_plugins(plugins_dir) assert len(plugins) == 1 assert plugins[0]["name"] == "test-plugin" def test_disabled_plugin_excluded(self, plugins_dir, installed_plugin): manager = PluginManager(plugins_dir) manager.disable("test-plugin") registry = PluginRegistry() plugins = registry.get_enabled_plugins(plugins_dir) assert len(plugins) == 0 def test_get_plugins_by_hook(self, plugins_dir, installed_plugin): registry = PluginRegistry() plugins = registry.get_plugins_by_hook(plugins_dir, "onFileRender") assert len(plugins) == 1 def test_get_plugins_by_wrong_hook(self, plugins_dir, installed_plugin): registry = PluginRegistry() plugins = registry.get_plugins_by_hook(plugins_dir, "onVaultMount") assert len(plugins) == 0 # ═══════════════════════════════════════════════════════════════════════════════ # C4 — ZIP Validation # ═══════════════════════════════════════════════════════════════════════════════ class TestZipValidation: """Test ZIP upload validation (C4).""" def test_valid_zip(self, tmp_path): import zipfile zip_path = tmp_path / "good-plugin.zip" with zipfile.ZipFile(zip_path, "w") as zf: zf.writestr("plugin.json", json.dumps({ "name": "zip-plugin", "version": "1.0.0", "description": "d", "author": "a", "main": "index.js", })) zf.writestr("index.js", "export default {}") result = _validate_plugin_zip(zip_path) assert result["name"] == "zip-plugin" def test_missing_manifest(self, tmp_path): import zipfile zip_path = tmp_path / "bad.zip" with zipfile.ZipFile(zip_path, "w") as zf: zf.writestr("index.js", "code") with pytest.raises(ValueError, match="plugin.json"): _validate_plugin_zip(zip_path) def test_missing_entry_point(self, tmp_path): import zipfile zip_path = tmp_path / "bad2.zip" with zipfile.ZipFile(zip_path, "w") as zf: zf.writestr("plugin.json", json.dumps({ "name": "x", "version": "1.0.0", "description": "d", "author": "a", "main": "index.js", })) with pytest.raises(ValueError, match="index.js"): _validate_plugin_zip(zip_path) def test_path_traversal_rejected(self, tmp_path): import zipfile zip_path = tmp_path / "traversal.zip" with zipfile.ZipFile(zip_path, "w") as zf: zf.writestr("plugin.json", json.dumps({ "name": "x", "version": "1.0.0", "description": "d", "author": "a", "main": "index.js", })) zf.writestr("index.js", "ok") zf.writestr("../../../etc/passwd", "evil") with pytest.raises(ValueError, match=r"(?i)path traversal"): _validate_plugin_zip(zip_path) def test_too_many_files_rejected(self, tmp_path): import zipfile zip_path = tmp_path / "toomany.zip" with zipfile.ZipFile(zip_path, "w") as zf: zf.writestr("plugin.json", json.dumps({ "name": "x", "version": "1.0.0", "description": "d", "author": "a", "main": "index.js", })) zf.writestr("index.js", "ok") for i in range(101): zf.writestr(f"file{i}.js", f"// {i}") with pytest.raises(ValueError, match="too many files"): _validate_plugin_zip(zip_path) def test_invalid_json_rejected(self, tmp_path): import zipfile zip_path = tmp_path / "badjson.zip" with zipfile.ZipFile(zip_path, "w") as zf: zf.writestr("plugin.json", "NOT JSON {{{") zf.writestr("index.js", "ok") with pytest.raises(ValueError, match="Invalid JSON"): _validate_plugin_zip(zip_path) # ═══════════════════════════════════════════════════════════════════════════════ # C5 — Directory Validation # ═══════════════════════════════════════════════════════════════════════════════ class TestDirectoryValidation: """Test directory-based plugin validation (C5).""" def test_valid_directory(self, tmp_path): plugin_dir = tmp_path / "good" plugin_dir.mkdir() (plugin_dir / MANIFEST_FILENAME).write_text(json.dumps({ "name": "dir-plugin", "version": "1.0.0", "description": "d", "author": "a", "main": "index.js", })) (plugin_dir / "index.js").write_text("export default {}") result = _validate_plugin_directory(plugin_dir) assert result["name"] == "dir-plugin" def test_missing_manifest_file(self, tmp_path): plugin_dir = tmp_path / "no-manifest" plugin_dir.mkdir() (plugin_dir / "index.js").write_text("code") with pytest.raises(ValueError, match="plugin.json"): _validate_plugin_directory(plugin_dir) def test_missing_entry_point(self, tmp_path): plugin_dir = tmp_path / "no-entry" plugin_dir.mkdir() (plugin_dir / MANIFEST_FILENAME).write_text(json.dumps({ "name": "x", "version": "1.0.0", "description": "d", "author": "a", "main": "missing.js", })) with pytest.raises(ValueError, match="missing.js"): _validate_plugin_directory(plugin_dir) # ═══════════════════════════════════════════════════════════════════════════════ # F1 — Plugin Loading / Execution (Web Worker sandbox API) # ═══════════════════════════════════════════════════════════════════════════════ class TestPluginLoading: """Test loading plugin code for sandbox execution.""" def test_get_plugin_code(self, plugins_dir, installed_plugin): manager = PluginManager(plugins_dir) code = manager.get_plugin_code("test-plugin", "index.js") assert "renderFile" in code def test_get_plugin_code_nonexistent(self, plugins_dir): manager = PluginManager(plugins_dir) with pytest.raises(ValueError, match="not found"): manager.get_plugin_code("nope", "index.js") def test_get_plugin_code_nonexistent_file(self, plugins_dir, installed_plugin): manager = PluginManager(plugins_dir) with pytest.raises(ValueError, match="not found"): manager.get_plugin_code("test-plugin", "missing.js") def test_get_hooks_for_plugin(self, plugins_dir, installed_plugin): manager = PluginManager(plugins_dir) hooks = manager.get_hooks("test-plugin") assert "onFileRender" in hooks assert hooks["onFileRender"] == "renderFile" def test_install_plugin_validates_manifest_on_install(self, plugins_dir): manager = PluginManager(plugins_dir) bad = {"name": "UPPERCASE", "version": "1.0.0", "description": "d", "author": "a", "main": "x.js"} with pytest.raises(ValueError, match="lowercase"): manager.install(bad, "code") class TestPluginsAPI: """Exercises the mounted /api/plugins/* router via TestClient (auth off).""" def test_plugin_list_empty(self, client): resp = client.get("/api/plugins?vault=TestVault") assert resp.status_code == 200 data = resp.json() assert isinstance(data, list) def test_plugin_template_returns_code(self, client): resp = client.get("/api/plugins/template") assert resp.status_code == 200 data = resp.json() assert isinstance(data, dict) assert "code" in data assert "manifest" in data assert data["manifest"]["name"] == "my-plugin" def test_plugin_hooks_endpoint(self, client): # /api/plugins/hooks doesn't exist; test a valid endpoint shape # /api/plugins/{name}/hooks requires a plugin name - test 404 resp = client.get("/api/plugins/nonexistent/hooks?vault=TestVault") assert resp.status_code == 404