feat: #61 Plugin system — backend API, sandboxed Web Worker, hooks wired to real app flow, user guide, 47+21 tests
CI / lint (push) Failing after 30s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
CI / security (push) Successful in 35s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s

This commit is contained in:
2026-09-10 09:01:38 -04:00
parent ac16fc1f0e
commit 30f2df3004
19 changed files with 2436 additions and 41 deletions
+461
View File
@@ -0,0 +1,461 @@
"""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