Files
shopdb-flask/tests/test_plugin_contract.py
cproudlock 534f179ee6 Make main the lean product a site receives
main was 16 days and 259 commits behind the line the product is actually built
on, at contract 0.30.0 against 0.35.0, with no per-PC credential code at all. It
was neither current nor curated - just stale. This brings it forward and, at the
same time, makes it mean something: main is what a receiving site installs, and
nothing else.

WHAT IT CARRIES. Twelve plugins - backups, computers, endpoints, guides,
knowledgebase, machines, network, notifications, printers, slides, tools,
warranty - plus applications, which is manifest-less core and always ships. The
only manifest dependency inside that set is endpoints on computers, and both are
in it, so the closure is self-contained.

WHAT IT DOES NOT. employees, geenforce, measuringtools, printedparts and usb
stay on the site line. So do the Windows installer tree, the VM rehearsal
helpers, the agent tooling config, one site's data loader, the internal audit
and findings records, and the planner spreadsheet. 1,135 tracked files against
1,438.

A REMOVAL THAT BREAKS THE APP IS NOT A REMOVAL, and two nearly shipped:

deploy/site-profile-universal.json still named all seventeen plugins. It is the
file docs/DEPLOY.md tells a site to provision from, so a fresh install would
have failed at `flask plugin apply-profile` with PluginError - the first thing a
receiving site does, broken by the branch meant to serve them. It now names the
twelve that exist, and its comment says the two must agree.

agent/ was never refreshed. Whoever populated this branch copied shopdb/,
plugins/, frontend/ and tests/ from the site line and left the agent tree at its
September state: 22 C# and PowerShell sources missing, including AgentCredentials,
EnrollmentService, AgentHttpFactory and the fleet-alert client, plus one file the
site line had deleted still present. The old tree is internally consistent, so it
compiles, and nothing in CI builds it - every gate was green over a server that
speaks enrollment and per-PC credentials to a bundled agent source that cannot.
Resynced whole.

DECISIONS THIS ENCODES, rather than defers:

- The plugin-authoring lab builds printedparts, whose manifest depends on
  employees, so it could not stay. The lab remains on the site line and main
  ships no build-along tutorial. That is a real loss for an adopting developer
  and is recorded here rather than discovered later.
- Recognition, Training and Recertification notifications are no longer seeded.
  Each splits one notification into a card per employee and renders a photo,
  which needs a plugin this branch does not bundle. Seeding them would offer an
  author a type that cannot resolve anybody. A site that installs employees gets
  them from that plugin's own seed.
- Windows/IIS is documented as the legacy path throughout, and the README leads
  with Docker, because that is what a receiving site gets.

The API description is regenerated for THIS branch - 347 paths and 483
operations, down from 415 and 573 - so the published contract describes what is
here rather than what the site line serves. docs/CONFIG.md lost the two
measuring-tool label keys the code no longer declares, which two lanes had
decided opposite ways.

Suite: 3124 passed, 6 skipped. Naming and style gates pass. The docs gates run
rather than skip, and every index line count is derived from the file it names.
2026-09-19 11:24:40 -04:00

310 lines
12 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 = ('backups', 'computers', 'endpoints', 'guides', 'knowledgebase', 'machines', 'network', 'notifications', 'printers', 'slides', 'tools', 'warranty')
@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)
def test_baseplugin_has_no_searchable_fields_hook():
"""get_searchable_fields was removed: global search is a core concern over
the asset model, and no plugin ever implemented the hook (contract 0.4.0)."""
assert not hasattr(BasePlugin, 'get_searchable_fields')
def test_baseplugin_has_dashboard_widgets_hook():
"""The dashboard widgets hook is on the contract surface and consumed."""
assert hasattr(BasePlugin, 'get_dashboard_widgets')
def test_baseplugin_has_reports_hook():
"""The reports hook is on the contract surface (contract 0.6.0)."""
assert hasattr(BasePlugin, 'get_reports')
@pytest.mark.parametrize('name', BUNDLED_PLUGINS)
def test_plugin_get_reports_is_iterable(plugin_instances, name):
"""get_reports returns a list (default empty)."""
plugin = plugin_instances[name]
reports = plugin.get_reports()
assert isinstance(reports, list)
# Each card carries an id/name/category plus exactly one of route/endpoint.
for card in reports:
assert card.get('id') and card.get('name') and card.get('category')
assert ('route' in card) ^ ('endpoint' in card), (
f'{name} report card must have exactly one of route/endpoint'
)
def test_baseplugin_has_permissions_hook():
"""The permissions hook is on the contract surface (contract 0.10.0)."""
assert hasattr(BasePlugin, 'get_permissions')
@pytest.mark.parametrize('name', BUNDLED_PLUGINS)
def test_plugin_get_permissions_shape(plugin_instances, name):
"""get_permissions returns a list of (name, description, category) entries."""
plugin = plugin_instances[name]
perms = plugin.get_permissions()
assert isinstance(perms, list)
for entry in perms:
if isinstance(entry, dict):
pname, category = entry['name'], entry.get('category')
else:
assert len(entry) == 3, f'{name}: entry must be a 3-tuple'
pname, _desc, category = entry
assert isinstance(pname, str) and '.' in pname, (
f'{name}: permission name {pname!r} must be dotted')
assert category, f'{name}: permission {pname} needs a category'
def test_baseplugin_has_frontend_contribution_hooks():
"""The four ADR-010 frontend-contribution hooks are on the contract (0.7.0)."""
for hook in ('get_settings_cards', 'get_asset_panels',
'get_map_overlays', 'get_asset_presentation'):
assert hasattr(BasePlugin, hook), f'{hook} missing from BasePlugin'
@pytest.mark.parametrize('name', BUNDLED_PLUGINS)
def test_plugin_frontend_hooks_return_lists(plugin_instances, name):
"""The four ADR-010 hooks default to a list on every bundled plugin."""
plugin = plugin_instances[name]
assert isinstance(plugin.get_settings_cards(), list)
assert isinstance(plugin.get_asset_panels(), list)
assert isinstance(plugin.get_map_overlays(), list)
assert isinstance(plugin.get_asset_presentation(), list)
def test_get_services_hook_has_consumer(app):
"""get_services is consumed by plugin_manager.get_service (no dead hook)."""
with app.app_context():
pm = app.extensions['plugin_manager']
assert hasattr(pm, 'get_service')
# Unknown service name resolves to None, not an error.
assert pm.get_service('definitely-not-a-real-service') is None
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'
# Skip migrations/ - Alembic env.py is framework glue that legitimately
# calls the shared runner in shopdb.plugins.alembic_template (see ADR-008),
# not plugin domain code. Mirrors the style check excluding versions/.
# Resolve each plugin dir before rglob: rglob does not descend symlinked
# directories, and external plugins arrive as symlinks per ADR-003.
files = [p for p in root.glob('*.py')]
for plugindir in sorted(root.iterdir()):
if not plugindir.is_dir():
continue
for path in plugindir.resolve().rglob('*.py'):
if '__pycache__' in path.parts or 'migrations' in path.parts:
continue
files.append(path)
return files
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)
)
def test_import_scan_covers_symlinked_plugins(tmp_path):
"""External plugins symlinked into plugins/ ARE scanned for violations.
Path.rglob does not descend symlinked directories, so a naive scan would
silently skip external plugins installed per ADR-003 (clone or symlink
into plugins/<name>/). The scanner resolves each plugin dir first; this
test plants a symlinked plugin with a violating import and asserts the
scan both sees the file and flags the violation.
"""
root = Path(__file__).resolve().parent.parent / 'plugins'
external = tmp_path / 'symlinkdemo'
external.mkdir()
# violating import: internal core path, not the contract surface
(external / 'plugin.py').write_text('from shopdb.extensions import db\n')
link = root / 'zzsymlinkdemo'
link.symlink_to(external, target_is_directory=True)
try:
scanned = {str(p) for p in _plugin_source_files()}
assert any('symlinkdemo' in s for s in scanned), (
'symlinked plugin sources were not scanned'
)
with pytest.raises(AssertionError, match='shopdb.extensions'):
test_plugins_only_import_contract_surface()
finally:
link.unlink()