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:
@@ -58,3 +58,12 @@ ZABBIX_TOKEN=
|
|||||||
# COLLECTOR_API_KEY_<PLUGINNAME> first, then COLLECTOR_API_KEY as fallback.
|
# COLLECTOR_API_KEY_<PLUGINNAME> first, then COLLECTOR_API_KEY as fallback.
|
||||||
# COLLECTOR_API_KEY=
|
# COLLECTOR_API_KEY=
|
||||||
# COLLECTOR_API_KEY_COMPUTERS=
|
# COLLECTOR_API_KEY_COMPUTERS=
|
||||||
|
|
||||||
|
# ---- Employee directory database (optional, read-only) ----
|
||||||
|
# Separate HR/employee lookup DB consumed by the notifications plugin and the
|
||||||
|
# public shopfloor kiosks. Leave unset if the feature is not used; there is no
|
||||||
|
# safe default for the password, so an unset password fails loud.
|
||||||
|
# EMPLOYEE_DB_HOST=
|
||||||
|
# EMPLOYEE_DB_USER=
|
||||||
|
# EMPLOYEE_DB_PASSWORD=
|
||||||
|
# EMPLOYEE_DB_NAME=wjf_employees
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ Edit `.env`:
|
|||||||
| `API_PORT` | No | Default 5001 |
|
| `API_PORT` | No | Default 5001 |
|
||||||
| `LOG_LEVEL` | No | Default INFO |
|
| `LOG_LEVEL` | No | Default INFO |
|
||||||
| `ZABBIX_URL`, `ZABBIX_TOKEN` | No | Only if printers plugin uses Zabbix |
|
| `ZABBIX_URL`, `ZABBIX_TOKEN` | No | Only if printers plugin uses Zabbix |
|
||||||
|
| `COLLECTOR_API_KEY` | No | Shared key for `/api/collector/*` ingest. Required only if unattended collectors push data. Endpoint fails closed (denies) when unset. |
|
||||||
|
| `COLLECTOR_API_KEY_<PLUGIN>` | No | Per-plugin override (e.g. `COLLECTOR_API_KEY_COMPUTERS`), checked before the shared key (ADR-006) |
|
||||||
|
| `EMPLOYEE_DB_HOST/USER/PASSWORD/NAME` | No | Read-only HR directory for notifications + kiosks. No safe default for the password. |
|
||||||
|
|
||||||
## Step 2: Bring up the stack
|
## Step 2: Bring up the stack
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ The contract is locked in [ADR-001](../docs/adr/ADR-001-asset-as-platform-contra
|
|||||||
The framework declares its contract version in `shopdb/__init__.py`:
|
The framework declares its contract version in `shopdb/__init__.py`:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
__contract_version__ = '0.2.0'
|
__contract_version__ = '0.3.0'
|
||||||
```
|
```
|
||||||
|
|
||||||
Each plugin's `manifest.json` declares the range of contract versions it supports:
|
Each plugin's `manifest.json` declares the range of contract versions it supports:
|
||||||
@@ -222,6 +222,31 @@ class ComputersPlugin(BasePlugin):
|
|||||||
|
|
||||||
If the hook returns `None` (the default), no collector endpoint is registered.
|
If the hook returns `None` (the default), no collector endpoint is registered.
|
||||||
|
|
||||||
|
### `apply_collector_payload(payload: Dict) -> Dict`
|
||||||
|
|
||||||
|
Companion to `get_collector_schema` (ADR-006). The generic
|
||||||
|
`/api/collector/<pluginname>` endpoint calls this after the payload passes
|
||||||
|
identity validation, to idempotently upsert an asset. Return a dict with at
|
||||||
|
least `action` (`created` | `updated` | `noop`), `assetid`, and `warnings`
|
||||||
|
(list).
|
||||||
|
|
||||||
|
This is a CONDITIONAL hook: it is only required when `get_collector_schema`
|
||||||
|
returns non-None. The BasePlugin default raises `NotImplementedError` (the
|
||||||
|
dispatcher turns that into a 500), so a plugin that declares a schema but
|
||||||
|
forgets the upsert fails loud. Plugins with no collector schema never need it.
|
||||||
|
The `test_schema_declaring_plugins_implement_apply` contract test enforces the
|
||||||
|
pairing.
|
||||||
|
|
||||||
|
```python
|
||||||
|
def apply_collector_payload(self, payload):
|
||||||
|
host = payload['hostname']
|
||||||
|
comp = Computer.query.filter(Computer.hostname.ilike(host)).first()
|
||||||
|
action = 'updated' if comp else 'created'
|
||||||
|
# ... create-or-update Asset + extension ...
|
||||||
|
db.session.commit()
|
||||||
|
return {'action': action, 'assetid': comp.assetid, 'warnings': []}
|
||||||
|
```
|
||||||
|
|
||||||
## Lifecycle hooks
|
## Lifecycle hooks
|
||||||
|
|
||||||
These run when the plugin's installation state changes. All optional.
|
These run when the plugin's installation state changes. All optional.
|
||||||
|
|||||||
@@ -36,10 +36,29 @@ The following are the public, versioned surface. Plugin authors may depend on th
|
|||||||
|
|
||||||
- `AuditLog` API: `audit_log(action, entitytype, entityid, ...)` for plugins to record audit entries with consistent schema
|
- `AuditLog` API: `audit_log(action, entitytype, entityid, ...)` for plugins to record audit entries with consistent schema
|
||||||
- `Setting` API: `plugin.get_setting(key)` and `plugin.set_setting(key, value)` for plugin-scoped config persisted via the core `Setting` model
|
- `Setting` API: `plugin.get_setting(key)` and `plugin.set_setting(key, value)` for plugin-scoped config persisted via the core `Setting` model
|
||||||
|
- `resolve_asset_position(asset)` for the documented position-resolution algorithm
|
||||||
|
|
||||||
|
#### Import surface (`shopdb.api`) - expanded in __contract_version__ 0.3.0
|
||||||
|
|
||||||
|
`shopdb.api` is the ONLY core module plugins may import (plus `shopdb.plugins.base`
|
||||||
|
for the ABC). Deep imports of `shopdb.core.*`, `shopdb.extensions`, or
|
||||||
|
`shopdb.utils.*` are contract violations enforced by the
|
||||||
|
`test_plugins_only_import_contract_surface` test. The surface re-exports:
|
||||||
|
|
||||||
|
- Infrastructure: `db`, `cache`
|
||||||
|
- Model bases: `BaseModel`, `AuditMixin`
|
||||||
|
- Core models: `Asset`, `AssetType`, `AssetStatus`, `Vendor`, `Model`,
|
||||||
|
`Communication`, `CommunicationType`, `Location`, `Setting`, `AuditLog`,
|
||||||
|
`Application`, `AppVersion`, `OperatingSystem`
|
||||||
|
- Responses: `success_response`, `error_response`, `paginated_response`, `ErrorCodes`
|
||||||
|
- Pagination: `get_pagination_params`, `paginate_query`
|
||||||
|
- Helpers: `audit_log`, `resolve_asset_position`; legacy `employee_connection`
|
||||||
|
|
||||||
|
Adding a name here is a minor (additive) contract change; removing one is major.
|
||||||
|
|
||||||
#### Plugin contract
|
#### Plugin contract
|
||||||
|
|
||||||
- `BasePlugin` ABC and its hooks (search, navigation, dashboard, relationships, collector schema)
|
- `BasePlugin` ABC and its hooks (search, navigation, dashboard, relationships, collector schema + `apply_collector_payload`)
|
||||||
|
|
||||||
#### Excluded from the contract for v1
|
#### Excluded from the contract for v1
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ This calls for a generalizable contract: any plugin that wants to accept externa
|
|||||||
|
|
||||||
## Decision
|
## Decision
|
||||||
|
|
||||||
`BasePlugin` gets one new optional hook:
|
`BasePlugin` gets two new hooks (added in __contract_version__ 0.2.x -> the surface is carried at 0.3.0):
|
||||||
|
|
||||||
```python
|
```python
|
||||||
def get_collector_schema(self) -> Optional[dict]:
|
def get_collector_schema(self) -> Optional[dict]:
|
||||||
@@ -29,9 +29,26 @@ def get_collector_schema(self) -> Optional[dict]:
|
|||||||
- 'fields': JSON Schema definitions for the rest of the payload.
|
- 'fields': JSON Schema definitions for the rest of the payload.
|
||||||
"""
|
"""
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def apply_collector_payload(self, payload: dict) -> dict:
|
||||||
|
"""Idempotently upsert an asset from a validated collector payload.
|
||||||
|
|
||||||
|
Called by /api/collector/<pluginname> after identity validation.
|
||||||
|
CONDITIONAL hook: required only when get_collector_schema returns
|
||||||
|
non-None. Default raises NotImplementedError (the dispatcher returns
|
||||||
|
500) so a schema-without-upsert fails loud. Returns a dict with at
|
||||||
|
least 'action' ('created'|'updated'|'noop'), 'assetid', 'warnings'.
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
```
|
```
|
||||||
|
|
||||||
Plugin loader auto-registers an endpoint at `/api/collector/<pluginname>` for each plugin returning a schema. Auth is API-key, separate from JWT. Per-plugin keys via env vars:
|
The pairing (schema present => apply implemented) is enforced by the
|
||||||
|
`test_schema_declaring_plugins_implement_apply` contract test.
|
||||||
|
|
||||||
|
A single dynamic dispatch route `/api/collector/<pluginname>` serves every
|
||||||
|
plugin that returns a schema (rather than registering a blueprint per plugin),
|
||||||
|
because Flask forbids `register_blueprint` after the first request and plugins
|
||||||
|
can be enabled at runtime. Auth is API-key, separate from JWT. Per-plugin keys via env vars:
|
||||||
|
|
||||||
- `COLLECTOR_API_KEY_<PLUGINNAME>` (preferred, plugin-specific)
|
- `COLLECTOR_API_KEY_<PLUGINNAME>` (preferred, plugin-specific)
|
||||||
- `COLLECTOR_API_KEY` (fallback, shared)
|
- `COLLECTOR_API_KEY` (fallback, shared)
|
||||||
@@ -125,7 +142,7 @@ Migration path:
|
|||||||
## References
|
## References
|
||||||
|
|
||||||
- `shopdb/core/api/collector.py` (legacy endpoint to be removed)
|
- `shopdb/core/api/collector.py` (legacy endpoint to be removed)
|
||||||
- `shopdb/plugins/base.py` (`get_collector_schema` hook to be added)
|
- `shopdb/plugins/base.py` (`get_collector_schema` + `apply_collector_payload` hooks)
|
||||||
- ADR-001 (asset model the collectors target)
|
- ADR-001 (asset model the collectors target)
|
||||||
- ADR-002 (collector schema is part of plugin contract; changes to the hook signature are major bumps)
|
- ADR-002 (collector schema is part of plugin contract; changes to the hook signature are major bumps)
|
||||||
- The PXE project (`/home/camp/projects/pxe/`) which feeds the computers collector
|
- The PXE project (`/home/camp/projects/pxe/`) which feeds the computers collector
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from flask import Flask, Blueprint
|
|||||||
import click
|
import click
|
||||||
|
|
||||||
from shopdb.plugins.base import BasePlugin, PluginMeta
|
from shopdb.plugins.base import BasePlugin, PluginMeta
|
||||||
from shopdb.api import db, AssetType, AssetStatus
|
from shopdb.api import db, AssetType
|
||||||
|
|
||||||
from .models import Computer, ComputerType, ComputerInstalledApp
|
from .models import Computer, ComputerType, ComputerInstalledApp
|
||||||
from .api import computers_bp
|
from .api import computers_bp
|
||||||
@@ -86,7 +86,7 @@ class ComputersPlugin(BasePlugin):
|
|||||||
def apply_collector_payload(self, payload: Dict) -> Dict:
|
def apply_collector_payload(self, payload: Dict) -> Dict:
|
||||||
"""Idempotent upsert of a PC from a collector payload (by hostname)."""
|
"""Idempotent upsert of a PC from a collector payload (by hostname)."""
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from shopdb.api import Asset, AssetType, Application, Communication, CommunicationType
|
from shopdb.api import Asset, Application, Communication, CommunicationType
|
||||||
|
|
||||||
warnings = []
|
warnings = []
|
||||||
hostname = (payload.get('hostname') or '').strip()
|
hostname = (payload.get('hostname') or '').strip()
|
||||||
@@ -101,6 +101,8 @@ class ComputersPlugin(BasePlugin):
|
|||||||
action = 'updated'
|
action = 'updated'
|
||||||
if not comp:
|
if not comp:
|
||||||
atype = AssetType.query.filter_by(assettype='computer').first()
|
atype = AssetType.query.filter_by(assettype='computer').first()
|
||||||
|
# statusid=1 is the first seeded asset status ("In Use"); a
|
||||||
|
# collector-discovered PC is by definition in use.
|
||||||
asset = Asset(assetnumber=hostname, assettypeid=atype.assettypeid,
|
asset = Asset(assetnumber=hostname, assettypeid=atype.assettypeid,
|
||||||
statusid=1)
|
statusid=1)
|
||||||
db.session.add(asset)
|
db.session.add(asset)
|
||||||
|
|||||||
@@ -44,6 +44,14 @@ def create_app(config_name: str = None) -> Flask:
|
|||||||
# Load instance config if exists
|
# Load instance config if exists
|
||||||
app.config.from_pyfile('config.py', silent=True)
|
app.config.from_pyfile('config.py', silent=True)
|
||||||
|
|
||||||
|
# Per-plugin collector keys (ADR-006) are dynamic env-vars
|
||||||
|
# (COLLECTOR_API_KEY_<PLUGINNAME>) that from_object cannot pick up because
|
||||||
|
# they are not class attributes. Copy them in explicitly so per-plugin
|
||||||
|
# credential isolation works in real deploys, not just tests.
|
||||||
|
for envname, envvalue in os.environ.items():
|
||||||
|
if envname.startswith('COLLECTOR_API_KEY_') and envvalue:
|
||||||
|
app.config[envname] = envvalue
|
||||||
|
|
||||||
# Ensure instance folder exists
|
# Ensure instance folder exists
|
||||||
os.makedirs(app.instance_path, exist_ok=True)
|
os.makedirs(app.instance_path, exist_ok=True)
|
||||||
|
|
||||||
|
|||||||
@@ -213,165 +213,9 @@ def seed_settings():
|
|||||||
"""Seed default system settings."""
|
"""Seed default system settings."""
|
||||||
from shopdb.extensions import db
|
from shopdb.extensions import db
|
||||||
from shopdb.core.models import Setting
|
from shopdb.core.models import Setting
|
||||||
|
from shopdb.core.api.settings import build_default_settings
|
||||||
|
|
||||||
defaults = [
|
defaults = build_default_settings()
|
||||||
# Zabbix integration
|
|
||||||
{
|
|
||||||
'key': 'zabbix_enabled',
|
|
||||||
'value': 'false',
|
|
||||||
'valuetype': 'boolean',
|
|
||||||
'category': 'integrations',
|
|
||||||
'description': 'Enable Zabbix integration for printer supply monitoring'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'key': 'zabbix_url',
|
|
||||||
'value': '',
|
|
||||||
'valuetype': 'string',
|
|
||||||
'category': 'integrations',
|
|
||||||
'description': 'Zabbix API URL (e.g., http://zabbix.example.com:8080)'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'key': 'zabbix_token',
|
|
||||||
'value': '',
|
|
||||||
'valuetype': 'string',
|
|
||||||
'category': 'integrations',
|
|
||||||
'description': 'Zabbix API authentication token'
|
|
||||||
},
|
|
||||||
# Email/SMTP settings
|
|
||||||
{
|
|
||||||
'key': 'smtp_enabled',
|
|
||||||
'value': 'false',
|
|
||||||
'valuetype': 'boolean',
|
|
||||||
'category': 'email',
|
|
||||||
'description': 'Enable email notifications and alerts'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'key': 'smtp_host',
|
|
||||||
'value': '',
|
|
||||||
'valuetype': 'string',
|
|
||||||
'category': 'email',
|
|
||||||
'description': 'SMTP server hostname'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'key': 'smtp_port',
|
|
||||||
'value': '587',
|
|
||||||
'valuetype': 'integer',
|
|
||||||
'category': 'email',
|
|
||||||
'description': 'SMTP server port (usually 587 for TLS, 465 for SSL, 25 for unencrypted)'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'key': 'smtp_username',
|
|
||||||
'value': '',
|
|
||||||
'valuetype': 'string',
|
|
||||||
'category': 'email',
|
|
||||||
'description': 'SMTP authentication username'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'key': 'smtp_password',
|
|
||||||
'value': '',
|
|
||||||
'valuetype': 'string',
|
|
||||||
'category': 'email',
|
|
||||||
'description': 'SMTP authentication password'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'key': 'smtp_use_tls',
|
|
||||||
'value': 'true',
|
|
||||||
'valuetype': 'boolean',
|
|
||||||
'category': 'email',
|
|
||||||
'description': 'Use TLS encryption for SMTP connection'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'key': 'smtp_from_address',
|
|
||||||
'value': '',
|
|
||||||
'valuetype': 'string',
|
|
||||||
'category': 'email',
|
|
||||||
'description': 'From address for outgoing emails'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'key': 'smtp_from_name',
|
|
||||||
'value': 'ShopDB',
|
|
||||||
'valuetype': 'string',
|
|
||||||
'category': 'email',
|
|
||||||
'description': 'From name for outgoing emails'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'key': 'alert_recipients',
|
|
||||||
'value': '',
|
|
||||||
'valuetype': 'string',
|
|
||||||
'category': 'email',
|
|
||||||
'description': 'Default email recipients for alerts (comma-separated)'
|
|
||||||
},
|
|
||||||
# Audit log settings
|
|
||||||
{
|
|
||||||
'key': 'audit_retention_days',
|
|
||||||
'value': '90',
|
|
||||||
'valuetype': 'integer',
|
|
||||||
'category': 'audit',
|
|
||||||
'description': 'Number of days to retain audit logs (0 = keep forever)'
|
|
||||||
},
|
|
||||||
# Authentication settings
|
|
||||||
{
|
|
||||||
'key': 'saml_enabled',
|
|
||||||
'value': 'false',
|
|
||||||
'valuetype': 'boolean',
|
|
||||||
'category': 'auth',
|
|
||||||
'description': 'Enable SAML SSO authentication'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'key': 'saml_idp_metadata_url',
|
|
||||||
'value': '',
|
|
||||||
'valuetype': 'string',
|
|
||||||
'category': 'auth',
|
|
||||||
'description': 'SAML Identity Provider metadata URL'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'key': 'saml_entity_id',
|
|
||||||
'value': '',
|
|
||||||
'valuetype': 'string',
|
|
||||||
'category': 'auth',
|
|
||||||
'description': 'SAML Service Provider entity ID (e.g., https://shopdb.example.com)'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'key': 'saml_acs_url',
|
|
||||||
'value': '',
|
|
||||||
'valuetype': 'string',
|
|
||||||
'category': 'auth',
|
|
||||||
'description': 'SAML Assertion Consumer Service URL'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'key': 'saml_allow_local_login',
|
|
||||||
'value': 'true',
|
|
||||||
'valuetype': 'boolean',
|
|
||||||
'category': 'auth',
|
|
||||||
'description': 'Allow local username/password login when SAML is enabled'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'key': 'saml_auto_create_users',
|
|
||||||
'value': 'true',
|
|
||||||
'valuetype': 'boolean',
|
|
||||||
'category': 'auth',
|
|
||||||
'description': 'Automatically create users on first SAML login'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'key': 'saml_admin_group',
|
|
||||||
'value': '',
|
|
||||||
'valuetype': 'string',
|
|
||||||
'category': 'auth',
|
|
||||||
'description': 'SAML group name that grants admin role'
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
# Asset identifier toggles, per identifier AND per asset type (ADR-001).
|
|
||||||
from shopdb.core.api.settings import IDENTIFIER_LABELS, IDENTIFIER_ASSETTYPES
|
|
||||||
for name, label in IDENTIFIER_LABELS.items():
|
|
||||||
for assettype in IDENTIFIER_ASSETTYPES:
|
|
||||||
defaults.append({
|
|
||||||
'key': f'identifier_{name}_{assettype}_enabled',
|
|
||||||
'value': 'true',
|
|
||||||
'valuetype': 'boolean',
|
|
||||||
'category': 'identifiers',
|
|
||||||
'description': f'Show the {label} identifier on {assettype} assets',
|
|
||||||
})
|
|
||||||
|
|
||||||
created = 0
|
created = 0
|
||||||
for d in defaults:
|
for d in defaults:
|
||||||
|
|||||||
@@ -62,9 +62,11 @@ class Config:
|
|||||||
|
|
||||||
# Read-only HR/employee directory database (separate from the app DB).
|
# Read-only HR/employee directory database (separate from the app DB).
|
||||||
# Credentials come from the environment; never hardcode them in source.
|
# Credentials come from the environment; never hardcode them in source.
|
||||||
|
# No safe default for the password: unset means empty, the connection
|
||||||
|
# fails loud rather than silently trying a guessed credential.
|
||||||
EMPLOYEE_DB_HOST = os.environ.get('EMPLOYEE_DB_HOST', 'localhost')
|
EMPLOYEE_DB_HOST = os.environ.get('EMPLOYEE_DB_HOST', 'localhost')
|
||||||
EMPLOYEE_DB_USER = os.environ.get('EMPLOYEE_DB_USER', 'root')
|
EMPLOYEE_DB_USER = os.environ.get('EMPLOYEE_DB_USER', '')
|
||||||
EMPLOYEE_DB_PASSWORD = os.environ.get('EMPLOYEE_DB_PASSWORD', 'rootpassword')
|
EMPLOYEE_DB_PASSWORD = os.environ.get('EMPLOYEE_DB_PASSWORD', '')
|
||||||
EMPLOYEE_DB_NAME = os.environ.get('EMPLOYEE_DB_NAME', 'wjf_employees')
|
EMPLOYEE_DB_NAME = os.environ.get('EMPLOYEE_DB_NAME', 'wjf_employees')
|
||||||
|
|
||||||
CACHE_TYPE = 'SimpleCache'
|
CACHE_TYPE = 'SimpleCache'
|
||||||
|
|||||||
@@ -81,7 +81,13 @@ def _collector_plugins():
|
|||||||
try:
|
try:
|
||||||
schema = plugin.get_collector_schema()
|
schema = plugin.get_collector_schema()
|
||||||
except Exception:
|
except Exception:
|
||||||
schema = None
|
# Fail loud in dev/test so a broken hook is visible; isolate the
|
||||||
|
# misbehaving plugin in prod and keep serving the healthy ones.
|
||||||
|
if current_app.config.get('DEBUG') or current_app.config.get('TESTING'):
|
||||||
|
raise
|
||||||
|
current_app.logger.exception(
|
||||||
|
'Plugin %s get_collector_schema failed', name)
|
||||||
|
continue
|
||||||
if schema:
|
if schema:
|
||||||
result[name] = (plugin, schema)
|
result[name] = (plugin, schema)
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -125,15 +125,19 @@ def get_navigation():
|
|||||||
# away (its routes stay registered until the next restart - Flask cannot
|
# away (its routes stay registered until the next restart - Flask cannot
|
||||||
# unregister a blueprint at runtime).
|
# unregister a blueprint at runtime).
|
||||||
for name, plugin in pm.get_all_plugins().items():
|
for name, plugin in pm.get_all_plugins().items():
|
||||||
try:
|
|
||||||
if not pm.registry.is_enabled(name):
|
if not pm.registry.is_enabled(name):
|
||||||
continue
|
continue
|
||||||
|
try:
|
||||||
items = plugin.get_navigation_items()
|
items = plugin.get_navigation_items()
|
||||||
for item in items:
|
for item in items:
|
||||||
item['plugin'] = name
|
item['plugin'] = name
|
||||||
all_items.extend(items)
|
all_items.extend(items)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
# Fail loud in dev/test; isolate a broken plugin in prod.
|
||||||
|
if current_app.config.get('DEBUG') or current_app.config.get('TESTING'):
|
||||||
|
raise
|
||||||
|
current_app.logger.exception(
|
||||||
|
'Plugin %s get_navigation_items failed', name)
|
||||||
|
|
||||||
# Add core information section items
|
# Add core information section items
|
||||||
all_items.extend([
|
all_items.extend([
|
||||||
|
|||||||
@@ -161,10 +161,12 @@ def create_setting():
|
|||||||
return success_response(setting.to_dict(), message='Setting created', http_code=201)
|
return success_response(setting.to_dict(), message='Setting created', http_code=201)
|
||||||
|
|
||||||
|
|
||||||
@settings_bp.route('/seed', methods=['POST'])
|
def build_default_settings():
|
||||||
@jwt_required()
|
"""Return the full default-settings list (identifier toggles + static).
|
||||||
def seed_default_settings():
|
|
||||||
"""Seed default settings if they don't exist."""
|
Shared by the /settings/seed route and the `flask seed settings` CLI so
|
||||||
|
the two definitions never drift.
|
||||||
|
"""
|
||||||
# Asset identifier feature toggles, per identifier AND per asset type.
|
# Asset identifier feature toggles, per identifier AND per asset type.
|
||||||
# Key format: identifier_<name>_<assettype>_enabled (boolean). Admins pick
|
# Key format: identifier_<name>_<assettype>_enabled (boolean). Admins pick
|
||||||
# which optional identifiers show on which asset types. See ADR-001.
|
# which optional identifiers show on which asset types. See ADR-001.
|
||||||
@@ -327,8 +329,15 @@ def seed_default_settings():
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
return defaults
|
||||||
|
|
||||||
|
|
||||||
|
@settings_bp.route('/seed', methods=['POST'])
|
||||||
|
@jwt_required()
|
||||||
|
def seed_default_settings():
|
||||||
|
"""Seed default settings if they don't exist."""
|
||||||
created = 0
|
created = 0
|
||||||
for d in defaults:
|
for d in build_default_settings():
|
||||||
if not Setting.query.filter_by(key=d['key']).first():
|
if not Setting.query.filter_by(key=d['key']).first():
|
||||||
setting = Setting(**d)
|
setting = Setting(**d)
|
||||||
db.session.add(setting)
|
db.session.add(setting)
|
||||||
|
|||||||
@@ -40,11 +40,18 @@ class PluginManager:
|
|||||||
self.migration_manager: Optional[PluginMigrationManager] = None
|
self.migration_manager: Optional[PluginMigrationManager] = None
|
||||||
self._app: Optional[Flask] = None
|
self._app: Optional[Flask] = None
|
||||||
self._db = None
|
self._db = None
|
||||||
|
# API prefixes already claimed by a registered plugin blueprint, to
|
||||||
|
# detect two plugins overlapping on the same /api/... namespace.
|
||||||
|
self._registered_prefixes: set = set()
|
||||||
|
|
||||||
def init_app(self, app: Flask, db) -> None:
|
def init_app(self, app: Flask, db) -> None:
|
||||||
"""Initialize plugin manager with Flask app."""
|
"""Initialize plugin manager with Flask app."""
|
||||||
self._app = app
|
self._app = app
|
||||||
self._db = db
|
self._db = db
|
||||||
|
# Reset per-app so the prefix-uniqueness guard tracks only this app's
|
||||||
|
# registrations (the manager is a process-wide singleton; tests build
|
||||||
|
# multiple apps from it).
|
||||||
|
self._registered_prefixes = set()
|
||||||
|
|
||||||
# Setup paths
|
# Setup paths
|
||||||
instance_path = Path(app.instance_path)
|
instance_path = Path(app.instance_path)
|
||||||
@@ -104,11 +111,18 @@ class PluginManager:
|
|||||||
# Register blueprint
|
# Register blueprint
|
||||||
blueprint = plugin.get_blueprint()
|
blueprint = plugin.get_blueprint()
|
||||||
if blueprint:
|
if blueprint:
|
||||||
self._app.register_blueprint(
|
prefix = plugin.meta.api_prefix
|
||||||
blueprint,
|
# Guard against two plugins claiming the same API prefix; Flask only
|
||||||
url_prefix=plugin.meta.api_prefix
|
# rejects duplicate blueprint names, not overlapping url_prefixes, so
|
||||||
|
# an overlap would silently shadow routes.
|
||||||
|
if prefix in self._registered_prefixes:
|
||||||
|
raise ValueError(
|
||||||
|
f"Plugin {plugin.meta.name} api_prefix '{prefix}' is already "
|
||||||
|
f"claimed by another blueprint"
|
||||||
)
|
)
|
||||||
logger.debug(f"Registered blueprint: {plugin.meta.api_prefix}")
|
self._app.register_blueprint(blueprint, url_prefix=prefix)
|
||||||
|
self._registered_prefixes.add(prefix)
|
||||||
|
logger.debug(f"Registered blueprint: {prefix}")
|
||||||
|
|
||||||
# Register CLI commands
|
# Register CLI commands
|
||||||
for cmd in plugin.get_cli_commands():
|
for cmd in plugin.get_cli_commands():
|
||||||
@@ -160,17 +174,16 @@ class PluginManager:
|
|||||||
logger.warning(f"Plugin {name} is already installed")
|
logger.warning(f"Plugin {name} is already installed")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Load plugin class
|
# Read metadata from the manifest (single source of truth) instead of
|
||||||
plugin_class = self.loader.load_plugin_class(name)
|
# instantiating the plugin class just to inspect deps/version.
|
||||||
if not plugin_class:
|
manifest = self.loader.load_manifest(name)
|
||||||
|
if not manifest:
|
||||||
logger.error(f"Plugin {name} not found")
|
logger.error(f"Plugin {name} not found")
|
||||||
return False
|
return False
|
||||||
|
manifest_version = manifest.get('version')
|
||||||
temp_plugin = plugin_class()
|
|
||||||
meta = temp_plugin.meta
|
|
||||||
|
|
||||||
# Check dependencies
|
# Check dependencies
|
||||||
for dep in meta.dependencies:
|
for dep in manifest.get('dependencies', []):
|
||||||
if not self.registry.is_installed(dep):
|
if not self.registry.is_installed(dep):
|
||||||
logger.error(
|
logger.error(
|
||||||
f"Plugin {name} requires {dep} to be installed first"
|
f"Plugin {name} requires {dep} to be installed first"
|
||||||
@@ -185,7 +198,7 @@ class PluginManager:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
# Register plugin
|
# Register plugin
|
||||||
self.registry.register(name, meta.version)
|
self.registry.register(name, manifest_version)
|
||||||
|
|
||||||
# Load the plugin
|
# Load the plugin
|
||||||
plugin = self.loader.load_plugin(name, self._app, self._db)
|
plugin = self.loader.load_plugin(name, self._app, self._db)
|
||||||
@@ -193,7 +206,7 @@ class PluginManager:
|
|||||||
self._register_plugin_components(plugin)
|
self._register_plugin_components(plugin)
|
||||||
plugin.on_install(self._app)
|
plugin.on_install(self._app)
|
||||||
|
|
||||||
logger.info(f"Installed plugin: {name} v{meta.version}")
|
logger.info(f"Installed plugin: {name} v{manifest_version}")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def uninstall_plugin(self, name: str, remove_data: bool = False) -> bool:
|
def uninstall_plugin(self, name: str, remove_data: bool = False) -> bool:
|
||||||
@@ -246,11 +259,11 @@ class PluginManager:
|
|||||||
logger.info(f"Plugin {name} is already enabled")
|
logger.info(f"Plugin {name} is already enabled")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# Check dependencies are enabled
|
# Check dependencies are enabled. Read deps from the manifest, not by
|
||||||
plugin_class = self.loader.load_plugin_class(name)
|
# instantiating the plugin class (manifest is the single source of
|
||||||
if plugin_class:
|
# truth; instantiating fires __init__ side effects unnecessarily).
|
||||||
temp = plugin_class()
|
manifest = self.loader.load_manifest(name)
|
||||||
for dep in temp.meta.dependencies:
|
for dep in manifest.get('dependencies', []):
|
||||||
if not self.registry.is_enabled(dep):
|
if not self.registry.is_enabled(dep):
|
||||||
logger.error(f"Cannot enable {name}: {dep} is not enabled")
|
logger.error(f"Cannot enable {name}: {dep} is not enabled")
|
||||||
return False
|
return False
|
||||||
|
|||||||
77
tests/test_core/test_identifiers.py
Normal file
77
tests/test_core/test_identifiers.py
Normal 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)
|
||||||
@@ -146,6 +146,23 @@ def test_baseplugin_has_collector_schema_hook():
|
|||||||
assert hasattr(BasePlugin, 'get_collector_schema')
|
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;
|
# 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.plugins.base is the plugin ABC. Anything else (shopdb.core.*,
|
||||||
# shopdb.extensions, shopdb.utils.*) is a contract violation per ADR-001.
|
# shopdb.extensions, shopdb.utils.*) is a contract violation per ADR-001.
|
||||||
|
|||||||
@@ -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('DATABASE_URL', 'mysql+pymysql://u:p@db/shopdb')
|
||||||
clean_env.setenv('CORS_ORIGINS', 'https://shopdb.example.com')
|
clean_env.setenv('CORS_ORIGINS', 'https://shopdb.example.com')
|
||||||
ProductionConfig.validate()
|
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 == ''
|
||||||
|
|||||||
Reference in New Issue
Block a user