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>
This commit is contained in:
cproudlock
2026-06-26 19:25:52 -04:00
parent f663cc5bbe
commit 5fa5160420
16 changed files with 273 additions and 198 deletions

View File

@@ -0,0 +1,77 @@
"""Tests for per-asset-type optional identifiers (gauge/maintenance refs).
Pins two behaviors that shipped without coverage:
1. gaugelabreference + maintenancereference round-trip through each plugin's
asset create AND update endpoints (computer, printer, network).
2. The per-type identifier seed produces one setting per
(identifier x asset type) pair.
"""
import pytest
from shopdb.core.api.settings import IDENTIFIER_LABELS, IDENTIFIER_ASSETTYPES
@pytest.fixture
def asset_types(db):
"""Seed the asset types the create endpoints look up by name."""
from shopdb.core.models import AssetType
for name in ('computer', 'printer', 'network_device'):
db.session.add(AssetType(assettype=name, pluginname=name,
tablename=name, description=name))
db.session.commit()
# (endpoint prefix, extension key in the response, extra create fields)
PLUGIN_CASES = [
('/api/computers', 'computer', {}),
('/api/printers', 'printer', {}),
('/api/network', 'networkdevice', {}),
]
@pytest.mark.parametrize('prefix,extkey,extra', PLUGIN_CASES)
def test_gauge_maintenance_roundtrip(client, db, auth_headers, asset_types,
prefix, extkey, extra):
"""Gauge/maintenance refs persist on create and update for each type."""
payload = {
'assetnumber': f'AST-{extkey}',
'gaugelabreference': 'GL-1',
'maintenancereference': 'MNT-1',
**extra,
}
created = client.post(prefix, json=payload, headers=auth_headers)
assert created.status_code == 201, created.get_json()
body = created.get_json()['data']
assert body['gaugelabreference'] == 'GL-1'
assert body['maintenancereference'] == 'MNT-1'
extid = body[extkey][f'{extkey}id'] if extkey != 'networkdevice' \
else body[extkey]['networkdeviceid']
updated = client.put(f'{prefix}/{extid}',
json={'gaugelabreference': 'GL-2',
'maintenancereference': 'MNT-2'},
headers=auth_headers)
assert updated.status_code == 200, updated.get_json()
fetched = client.get(f'{prefix}/{extid}', headers=auth_headers).get_json()['data']
assert fetched['gaugelabreference'] == 'GL-2'
assert fetched['maintenancereference'] == 'MNT-2'
def test_per_type_identifier_seed_count(client, db, auth_headers):
"""Seeding produces one boolean setting per identifier x asset type."""
response = client.post('/api/settings/seed', headers=auth_headers)
assert response.status_code == 200
from shopdb.core.models import Setting
keys = {s.key for s in Setting.query.filter_by(category='identifiers').all()}
expected = {
f'identifier_{name}_{assettype}_enabled'
for name in IDENTIFIER_LABELS
for assettype in IDENTIFIER_ASSETTYPES
}
assert expected <= keys
assert len(expected) == len(IDENTIFIER_LABELS) * len(IDENTIFIER_ASSETTYPES)

View File

@@ -146,6 +146,23 @@ def test_baseplugin_has_collector_schema_hook():
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.

View File

@@ -68,3 +68,23 @@ def test_production_validate_passes_with_complete_config(clean_env):
clean_env.setenv('DATABASE_URL', 'mysql+pymysql://u:p@db/shopdb')
clean_env.setenv('CORS_ORIGINS', 'https://shopdb.example.com')
ProductionConfig.validate()
def test_per_plugin_collector_key_loaded_from_env(monkeypatch):
"""COLLECTOR_API_KEY_<PLUGIN> is a dynamic env var; create_app must load it.
from_object only copies class attributes, so per-plugin keys (ADR-006)
would be invisible without the explicit env scan in create_app.
"""
from shopdb import create_app
monkeypatch.setenv('COLLECTOR_API_KEY_COMPUTERS', 'computers-secret')
app = create_app('testing')
assert app.config.get('COLLECTOR_API_KEY_COMPUTERS') == 'computers-secret'
def test_employee_db_password_has_no_default():
"""No safe default for the employee-DB password: unset means empty."""
if 'EMPLOYEE_DB_PASSWORD' not in os.environ:
from shopdb.config import Config
assert Config.EMPLOYEE_DB_PASSWORD == ''