Additive, zero-risk-to-running-sites prep for the plugin catalog. No distribution or lean-build behavior yet; fixes latent bugs and adds the declarative + validate tooling later phases build on. Fixes: - upgrade_all_plugins iterates registry.get_all(); only adopted plugins are migrated. Removes the phantom hasattr(registry, 'list_installed') probe that always fell through to migrating every folder on disk (unadopted DDL ran with full DB rights on every deploy). - Reverse-dependency checks on uninstall/disable read dependencies from the manifest on disk via _installed_dependents, so an installed-but-unloaded or disabled dependent is counted. Uninstall blocks on any installed dependent; disable blocks on an enabled dependent. - _sort_by_dependencies detects a dependency cycle (back edge in the DFS) and raises PluginDependencyError instead of looping or dropping a plugin. New: - flask plugin validate <name>: manifest loads + name match, manifest-schema check, core_version admits the framework contract, declared dependencies exist on disk. No new dependency (lightweight checker); schema ships in the package at shopdb/plugins/manifest_schema.json (docs/ is stripped on publish). The check caught that provides is an object, not an array. - flask plugin apply-profile <file>: declarative install AND enable of a chosen plugin set plus its hard-dependency closure, in dependency order, idempotent. Replaces the hand-ordered runbook sequences that could enable a plugin that was never installed. deploy/site-profile.example.json template. - Dockerfile header corrected (all 13 catalog plugins, not "eleven core"). 10 new lifecycle tests (reverse-deps from disk, cycle detection, upgrade-all scope, profile closure, schema, all 13 manifests match schema). 1018 pass, naming green.
303 lines
11 KiB
Python
303 lines
11 KiB
Python
"""Plugin lifecycle + registry tests.
|
|
|
|
Covers the framework's own core feature - the plugin manager's enable/disable
|
|
guards and permission seeding, plus the registry's persistence and the
|
|
equipment->machines rename migration. These paths had no direct coverage; a
|
|
dropped dependency check or a broken rename migration would let an operator
|
|
disable a required plugin (breaking boot) with no failing test.
|
|
"""
|
|
|
|
import json
|
|
|
|
import pytest
|
|
|
|
from shopdb.plugins import PluginManager
|
|
from shopdb.plugins.loader import PluginLoader
|
|
from shopdb.plugins.registry import PluginRegistry
|
|
|
|
|
|
# --- Registry persistence + rename migration --------------------------------
|
|
|
|
def test_registry_disable_survives_reload(tmp_path):
|
|
"""A disabled plugin stays disabled when the registry is re-read."""
|
|
path = tmp_path / 'plugins.json'
|
|
reg = PluginRegistry(path)
|
|
reg.register('demo', '1.0.0', enabled=True)
|
|
reg.disable('demo')
|
|
|
|
reloaded = PluginRegistry(path)
|
|
assert reloaded.is_installed('demo')
|
|
assert not reloaded.is_enabled('demo')
|
|
|
|
|
|
def test_registry_recovers_from_corrupt_file(tmp_path):
|
|
"""An unparseable plugins.json loads as empty instead of crashing."""
|
|
path = tmp_path / 'plugins.json'
|
|
path.write_text('{ this is not valid json')
|
|
reg = PluginRegistry(path)
|
|
assert reg.get_all() == {}
|
|
|
|
|
|
def test_registry_migrates_equipment_to_machines(tmp_path):
|
|
"""A legacy 'equipment' entry is carried over to 'machines' on load (the
|
|
real plugins/machines dir exists), preserving enabled state, once."""
|
|
path = tmp_path / 'plugins.json'
|
|
seed = PluginRegistry(path)
|
|
seed.register('equipment', '1.0.0', enabled=True)
|
|
assert seed.is_installed('equipment')
|
|
|
|
migrated = PluginRegistry(path) # _load runs the rename migration
|
|
assert migrated.is_installed('machines')
|
|
assert migrated.is_enabled('machines')
|
|
assert not migrated.is_installed('equipment')
|
|
|
|
# Persisted: a third read sees machines, and never re-migrates.
|
|
again = PluginRegistry(path)
|
|
assert again.is_installed('machines')
|
|
|
|
|
|
# --- Manager enable/disable dependency guards + permission seeding -----------
|
|
|
|
def _write_plugin(plugins_dir, name, deps=None, permissions=None):
|
|
"""Write a minimal valid synthetic plugin (manifest.json + plugin.py)."""
|
|
pdir = plugins_dir / name
|
|
pdir.mkdir()
|
|
(pdir / 'manifest.json').write_text(json.dumps({
|
|
'name': name,
|
|
'version': '1.0.0',
|
|
'description': f'synthetic {name}',
|
|
'dependencies': deps or [],
|
|
'core_version': '>=0.1.0,<1.0.0',
|
|
'api_prefix': f'/api/{name}',
|
|
}))
|
|
(pdir / '__init__.py').write_text('')
|
|
(pdir / 'plugin.py').write_text(
|
|
'from shopdb.plugins.base import BasePlugin, PluginMeta\n\n'
|
|
'class ThePlugin(BasePlugin):\n'
|
|
' @property\n'
|
|
' def meta(self):\n'
|
|
f' return PluginMeta(name={name!r}, version="1.0.0",\n'
|
|
f' description="synthetic", dependencies={deps or []!r})\n'
|
|
' def get_blueprint(self):\n'
|
|
' return None\n'
|
|
' def get_models(self):\n'
|
|
' return []\n'
|
|
' def get_permissions(self):\n'
|
|
f' return {permissions or []!r}\n'
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def manager(app, db, tmp_path):
|
|
"""A PluginManager wired to a temp plugins dir + registry, with synthetic
|
|
plugins 'alpha' (owns a permission) and 'beta' (depends on alpha)."""
|
|
plugins_dir = tmp_path / 'plugins'
|
|
plugins_dir.mkdir()
|
|
_write_plugin(plugins_dir, 'alpha',
|
|
permissions=[('alpha.view', 'View alpha', 'alpha')])
|
|
_write_plugin(plugins_dir, 'beta', deps=['alpha'])
|
|
|
|
registry = PluginRegistry(tmp_path / 'plugins.json')
|
|
registry.register('alpha', '1.0.0', enabled=False)
|
|
registry.register('beta', '1.0.0', enabled=False)
|
|
|
|
pluginmanager = PluginManager()
|
|
pluginmanager._app = app
|
|
pluginmanager._db = db
|
|
pluginmanager.registry = registry
|
|
pluginmanager.loader = PluginLoader(plugins_dir, registry)
|
|
pluginmanager.migration_manager = None
|
|
return pluginmanager
|
|
|
|
|
|
def test_enable_blocked_until_dependency_enabled(manager):
|
|
"""beta depends on alpha: enabling beta first is refused."""
|
|
assert manager.enable_plugin('beta') is False
|
|
assert not manager.registry.is_enabled('beta')
|
|
|
|
assert manager.enable_plugin('alpha') is True
|
|
assert manager.enable_plugin('beta') is True
|
|
|
|
|
|
def test_disable_blocked_while_dependent_enabled(manager):
|
|
"""alpha cannot be disabled while beta (which depends on it) is enabled."""
|
|
manager.enable_plugin('alpha')
|
|
manager.enable_plugin('beta')
|
|
|
|
assert manager.disable_plugin('alpha') is False
|
|
assert manager.registry.is_enabled('alpha')
|
|
|
|
# Disable the dependent first, then alpha frees up.
|
|
assert manager.disable_plugin('beta') is True
|
|
assert manager.disable_plugin('alpha') is True
|
|
|
|
|
|
def test_enable_seeds_plugin_permissions(manager, app, db):
|
|
"""Enabling a plugin idempotently seeds its declared RBAC permissions."""
|
|
from shopdb.core.models import Permission
|
|
|
|
with app.app_context():
|
|
assert Permission.query.filter_by(name='alpha.view').first() is None
|
|
|
|
assert manager.enable_plugin('alpha') is True
|
|
with app.app_context():
|
|
assert Permission.query.filter_by(name='alpha.view').first() is not None
|
|
|
|
# Re-enable is a no-op (already enabled) and never duplicates the row.
|
|
manager.enable_plugin('alpha')
|
|
with app.app_context():
|
|
assert Permission.query.filter_by(name='alpha.view').count() == 1
|
|
|
|
|
|
# --- ADR-013 Phase 0: reverse-deps from disk, cycles, upgrade-all, profiles --
|
|
|
|
def test_disable_blocked_by_unloaded_dependent(manager):
|
|
"""alpha cannot be disabled while an enabled dependent exists, even when
|
|
that dependent was never loaded as an instance (manifest read from disk)."""
|
|
# Mark both enabled directly in the registry WITHOUT loading beta.
|
|
manager.registry.enable('alpha')
|
|
manager.registry.enable('beta')
|
|
assert manager.loader.get_loaded_plugin('beta') is None
|
|
|
|
# The old loaded-instance-only check would wrongly allow this.
|
|
assert manager.disable_plugin('alpha') is False
|
|
assert manager.registry.is_enabled('alpha')
|
|
|
|
|
|
def test_uninstall_blocked_by_disabled_dependent(manager):
|
|
"""An installed-but-DISABLED dependent still blocks uninstalling its
|
|
dependency (uninstall reads all installed manifests, not enabled instances)."""
|
|
assert manager.registry.is_installed('beta')
|
|
assert not manager.registry.is_enabled('beta')
|
|
|
|
assert manager.uninstall_plugin('alpha') is False
|
|
assert manager.registry.is_installed('alpha')
|
|
|
|
|
|
def test_dependency_cycle_detected(tmp_path):
|
|
"""A -> B -> A raises instead of looping or silently dropping a plugin."""
|
|
from shopdb.exceptions import PluginDependencyError
|
|
|
|
plugins_dir = tmp_path / 'plugins'
|
|
plugins_dir.mkdir()
|
|
_write_plugin(plugins_dir, 'aa', deps=['bb'])
|
|
_write_plugin(plugins_dir, 'bb', deps=['aa'])
|
|
loader = PluginLoader(plugins_dir, PluginRegistry(tmp_path / 'plugins.json'))
|
|
|
|
with pytest.raises(PluginDependencyError):
|
|
loader._sort_by_dependencies(['aa', 'bb'])
|
|
|
|
|
|
def test_upgrade_all_only_touches_registered(manager, tmp_path):
|
|
"""upgrade-all migrates only ADOPTED plugins; an unregistered folder on
|
|
disk is never migrated as a side effect."""
|
|
_write_plugin(tmp_path / 'plugins', 'gamma') # on disk, not registered
|
|
|
|
class StubMigrationManager:
|
|
def has_pending_migrations(self, name):
|
|
return False
|
|
|
|
manager.migration_manager = StubMigrationManager()
|
|
results = manager.upgrade_all_plugins()
|
|
|
|
assert set(results) == {'alpha', 'beta'}
|
|
assert 'gamma' not in results
|
|
|
|
|
|
def test_apply_profile_enables_dependency_closure(manager):
|
|
"""Naming only the dependent enables it AND its dependency, in order."""
|
|
result = manager.apply_profile(['beta'])
|
|
|
|
assert manager.registry.is_enabled('alpha')
|
|
assert manager.registry.is_enabled('beta')
|
|
assert 'alpha' in result['enabled']
|
|
assert 'beta' in result['enabled']
|
|
|
|
|
|
def test_apply_profile_installs_and_enables_fresh_plugin(manager, tmp_path):
|
|
"""A plugin on disk but not yet registered is installed AND enabled."""
|
|
_write_plugin(tmp_path / 'plugins', 'gamma')
|
|
|
|
class StubMigrationManager:
|
|
def has_pending_migrations(self, name):
|
|
return False
|
|
|
|
def run_plugin_migrations(self, name, revision='head'):
|
|
return True
|
|
|
|
def has_unapplied_migrations(self, name):
|
|
return False
|
|
|
|
manager.migration_manager = StubMigrationManager()
|
|
result = manager.apply_profile(['gamma'])
|
|
|
|
assert manager.registry.is_installed('gamma')
|
|
assert manager.registry.is_enabled('gamma')
|
|
assert 'gamma' in result['installed']
|
|
|
|
|
|
def test_apply_profile_rejects_unknown_plugin(manager):
|
|
"""A profile naming a plugin that is not on disk fails loudly."""
|
|
from shopdb.exceptions import PluginError
|
|
|
|
with pytest.raises(PluginError):
|
|
manager.apply_profile(['doesnotexist'])
|
|
|
|
|
|
def test_manifest_schema_check():
|
|
"""The no-dependency manifest schema check flags missing/mistyped fields
|
|
and accepts a valid manifest (with unknown extra fields)."""
|
|
from shopdb.plugins.cli import _check_against_schema, _load_manifest_schema
|
|
|
|
schema = _load_manifest_schema()
|
|
|
|
good = {'name': 'x', 'version': '1.0.0', 'description': 'd',
|
|
'dependencies': [], 'tier': 'optional', 'somefuture': 42}
|
|
assert _check_against_schema(good, schema) == []
|
|
|
|
bad = {'name': 'x', 'version': '1.0.0', 'description': 'd',
|
|
'dependencies': 'notalist', 'tier': 'bogus',
|
|
'default_enabled': 'yes'}
|
|
errors = _check_against_schema(bad, schema)
|
|
assert any('dependencies' in e for e in errors)
|
|
assert any('tier' in e for e in errors)
|
|
assert any('default_enabled' in e for e in errors)
|
|
|
|
assert any('version' in e for e in _check_against_schema({'name': 'x'}, schema))
|
|
|
|
|
|
def test_all_bundled_manifests_match_schema(app):
|
|
"""Every bundled plugin's manifest passes the schema - the schema must
|
|
describe reality, not an idealized shape."""
|
|
from shopdb.plugins.cli import _check_against_schema, _load_manifest_schema
|
|
|
|
schema = _load_manifest_schema()
|
|
pm = app.extensions['plugin_manager']
|
|
for name in pm.loader.discover_plugins():
|
|
manifest = pm.loader.load_manifest(name)
|
|
assert _check_against_schema(manifest, schema) == [], name
|
|
|
|
|
|
def test_validate_bundled_plugin_passes(app):
|
|
"""`flask plugin validate <bundled>` exits clean on a real plugin."""
|
|
runner = app.test_cli_runner()
|
|
result = runner.invoke(args=['plugin', 'validate', 'printers'])
|
|
assert result.exit_code in (0, None), result.output
|
|
assert 'valid' in result.output
|
|
|
|
|
|
# --- Seed idempotency -------------------------------------------------------
|
|
|
|
def test_seed_settings_is_idempotent(app, db):
|
|
"""A second `flask seed settings` pass creates zero net-new rows."""
|
|
from shopdb.core.models import Setting
|
|
|
|
runner = app.test_cli_runner()
|
|
assert runner.invoke(args=['seed', 'settings']).exit_code in (0, None)
|
|
with app.app_context():
|
|
first = Setting.query.count()
|
|
assert runner.invoke(args=['seed', 'settings']).exit_code in (0, None)
|
|
with app.app_context():
|
|
second = Setting.query.count()
|
|
assert second == first
|