Files
shopdb-flask/tests/test_plugin_contract.py
cproudlock 5fa5160420 Apply skill-driven review fixes: security, hook isolation, tests, docs
Addresses findings from a 6-lens review against the project skills
(defining-asset-contract, enforcing-plugin-contract, hardening-flask-config,
integrating-plugin-hooks, pinning-flask-behavior, simplifying-python).

Security (hardening-flask-config):
- Load per-plugin COLLECTOR_API_KEY_<PLUGIN> from env in create_app. from_object
  only copies class attributes, so per-plugin keys (ADR-006) were dead in real
  deploys and silently fell back to the shared key.
- EMPLOYEE_DB_USER/PASSWORD no longer default to root/rootpassword (no safe
  default for a secret; unset fails loud). Documented in .env.example + DEPLOY.md.
- COLLECTOR_API_KEY + per-plugin + EMPLOYEE_DB_* added to .env.example/DEPLOY.md.

Hook isolation (integrating-plugin-hooks):
- collector _collector_plugins and dashboard get_navigation now re-raise in
  dev/test and log+isolate in prod, instead of silently swallowing a broken
  plugin hook.

Plugin loader (enforcing-plugin-contract):
- enable_plugin/install_plugin read dependencies+version from the manifest
  instead of instantiating the plugin class.
- _register_plugin_components rejects a second plugin claiming an already-used
  api_prefix (reset per app in init_app).

Tests (pinning-flask-behavior):
- test_identifiers.py: gauge/maintenance round-trip on computer/printer/network
  create+update; per-type seed yields the 12 identifier keys.
- contract tests for apply_collector_payload presence + schema-declarers-implement.
- security tests for per-plugin key env loading + no employee-db password default.

Docs/contract sync (defining-asset-contract):
- PLUGIN-HOOKS.md documents apply_collector_payload; stale 0.2.0 -> 0.3.0.
- ADR-006 documents apply_collector_payload + single-dispatch rationale.
- ADR-001 enumerates the expanded shopdb.api import surface.

Simplify (simplifying-python):
- De-duplicate the 21-entry settings defaults: shared build_default_settings()
  used by both the /settings/seed route and the CLI (were drifting copies).
- Remove dead AssetStatus import + redundant AssetType local import in computers
  plugin; comment the statusid=1 collector default.

153 tests pass (was 145), naming/style green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 19:25:52 -04:00

199 lines
7.3 KiB
Python

"""Plugin contract compliance tests.
Asserts every bundled plugin satisfies the contract surface declared in
ADR-001 and the BasePlugin ABC. Run on every change to BasePlugin or any
plugin's plugin.py / manifest.json.
"""
import json
import re
from pathlib import Path
import pytest
from flask import Blueprint
from shopdb import __contract_version__
from shopdb.plugins import plugin_manager
from shopdb.plugins.base import BasePlugin, PluginMeta
BUNDLED_PLUGINS = ('computers', 'equipment', 'network', 'notifications', 'printers', 'usb')
@pytest.fixture
def plugin_classes(app):
"""Load each bundled plugin's class without instantiating in app context."""
classes = {}
with app.app_context():
loader = plugin_manager.loader
for name in BUNDLED_PLUGINS:
cls = loader.load_plugin_class(name)
assert cls is not None, f'Plugin class for {name} could not be loaded'
classes[name] = cls
return classes
@pytest.fixture
def plugin_instances(app, plugin_classes):
"""Instantiate each bundled plugin in app context."""
return {name: cls() for name, cls in plugin_classes.items()}
def test_contract_version_is_set():
"""Framework declares a contract version."""
assert __contract_version__
parts = __contract_version__.split('.')
assert len(parts) == 3, f'Expected semver X.Y.Z, got {__contract_version__}'
for part in parts:
assert part.isdigit(), f'Non-numeric semver part in {__contract_version__}'
@pytest.mark.parametrize('name', BUNDLED_PLUGINS)
def test_plugin_class_subclasses_baseplugin(plugin_classes, name):
"""Each plugin class extends BasePlugin."""
cls = plugin_classes[name]
assert issubclass(cls, BasePlugin), f'{name} does not subclass BasePlugin'
@pytest.mark.parametrize('name', BUNDLED_PLUGINS)
def test_plugin_manifest_has_required_fields(name):
"""Each plugin's manifest.json declares the required fields."""
manifestpath = Path('plugins') / name / 'manifest.json'
assert manifestpath.exists(), f'{name} is missing manifest.json'
manifest = json.loads(manifestpath.read_text())
assert manifest.get('name') == name
assert manifest.get('version'), f'{name} manifest missing version'
assert manifest.get('description'), f'{name} manifest missing description'
assert manifest.get('core_version'), f'{name} manifest missing core_version'
@pytest.mark.parametrize('name', BUNDLED_PLUGINS)
def test_plugin_meta_returns_pluginmeta(plugin_instances, name):
"""plugin.meta returns a PluginMeta instance with sane fields."""
plugin = plugin_instances[name]
meta = plugin.meta
assert isinstance(meta, PluginMeta)
assert meta.name == name
assert meta.version
assert meta.api_prefix and meta.api_prefix.startswith('/api/')
@pytest.mark.parametrize('name', BUNDLED_PLUGINS)
def test_plugin_get_blueprint_returns_blueprint_or_none(plugin_instances, name):
"""get_blueprint returns a Flask Blueprint or None."""
plugin = plugin_instances[name]
bp = plugin.get_blueprint()
assert bp is None or isinstance(bp, Blueprint), (
f'{name}.get_blueprint returned {type(bp).__name__}'
)
@pytest.mark.parametrize('name', BUNDLED_PLUGINS)
def test_plugin_get_models_returns_list(plugin_instances, name):
"""get_models returns a list of model classes."""
plugin = plugin_instances[name]
models = plugin.get_models()
assert isinstance(models, list), f'{name}.get_models did not return a list'
for model in models:
assert hasattr(model, '__tablename__'), (
f'{name}.get_models returned {model} without __tablename__'
)
@pytest.mark.parametrize('name', BUNDLED_PLUGINS)
def test_plugin_get_collector_schema_returns_dict_or_none(plugin_instances, name):
"""get_collector_schema returns None or a dict with identityfield + fields."""
plugin = plugin_instances[name]
schema = plugin.get_collector_schema()
if schema is None:
return
assert isinstance(schema, dict)
assert 'identityfield' in schema, (
f'{name}.get_collector_schema must declare identityfield'
)
assert 'fields' in schema, (
f'{name}.get_collector_schema must declare fields'
)
@pytest.mark.parametrize('name', BUNDLED_PLUGINS)
def test_plugin_get_navigation_items_is_iterable(plugin_instances, name):
"""get_navigation_items returns an iterable (default empty list)."""
plugin = plugin_instances[name]
items = plugin.get_navigation_items()
assert isinstance(items, list)
@pytest.mark.parametrize('name', BUNDLED_PLUGINS)
def test_plugin_get_searchable_fields_is_iterable(plugin_instances, name):
"""get_searchable_fields returns a list (default empty)."""
plugin = plugin_instances[name]
fields = plugin.get_searchable_fields()
assert isinstance(fields, list)
def test_baseplugin_does_not_have_event_handlers_hook():
"""The removed get_event_handlers hook must not be on BasePlugin."""
assert not hasattr(BasePlugin, 'get_event_handlers'), (
'get_event_handlers was removed for v1. See ADR-001 contract surface.'
)
def test_baseplugin_has_collector_schema_hook():
"""The collector schema hook is on the contract surface."""
assert hasattr(BasePlugin, 'get_collector_schema')
def test_baseplugin_has_apply_collector_payload_hook():
"""The collector upsert hook is on the contract surface (ADR-006)."""
assert hasattr(BasePlugin, 'apply_collector_payload')
def test_schema_declaring_plugins_implement_apply(plugin_instances):
"""Any plugin returning a collector schema must implement the upsert hook."""
for name, plugin in plugin_instances.items():
if plugin.get_collector_schema() is not None:
overridden = type(plugin).apply_collector_payload \
is not BasePlugin.apply_collector_payload
assert overridden, (
f'Plugin {name} declares a collector schema but does not '
f'override apply_collector_payload'
)
# Imports a plugin may make from the core. shopdb.api is the contract surface;
# shopdb.plugins.base is the plugin ABC. Anything else (shopdb.core.*,
# shopdb.extensions, shopdb.utils.*) is a contract violation per ADR-001.
ALLOWED_CORE_IMPORTS = ('shopdb.api', 'shopdb.plugins.base')
_PLUGIN_IMPORT_RE = re.compile(
r'^\s*(?:from (shopdb[\w.]*) import|import (shopdb[\w.]*))', re.MULTILINE
)
def _plugin_source_files():
root = Path(__file__).resolve().parent.parent / 'plugins'
return [p for p in root.rglob('*.py') if '__pycache__' not in p.parts]
def test_plugins_only_import_contract_surface():
"""Plugins must import core code only via shopdb.api / shopdb.plugins.base."""
violations = []
for path in _plugin_source_files():
text = path.read_text()
for match in _PLUGIN_IMPORT_RE.finditer(text):
module = match.group(1) or match.group(2)
if not module.startswith('shopdb'):
continue
if any(module == a or module.startswith(a + '.')
for a in ALLOWED_CORE_IMPORTS):
continue
line = text[:match.start()].count('\n') + 1
violations.append(f'{path.name}:{line} imports {module}')
assert not violations, (
'Plugins must import core only via shopdb.api or shopdb.plugins.base. '
'Violations:\n' + '\n'.join(violations)
)