Add ADR-006 generic plugin collector contract
Implements the plugin collector contract from ADR-006 so any plugin can accept idempotent inventory ingest, not just PCs. - base.py: add apply_collector_payload hook (companion to get_collector_schema), raises NotImplementedError by default for plugins that declare a schema but do not implement the upsert. - collector.py: generic POST /api/collector/<plugin> dispatch with per-plugin API key (COLLECTOR_API_KEY_<PLUGINNAME> with COLLECTOR_API_KEY fallback), schema-driven identity validation, idempotent upsert, ADR-006 response contract (status, action, assetid, identityvalue, warnings), audit log. JWT-protected GET /api/collector/_schemas lists registered schemas. Legacy /pc, /apps, /heartbeat, /bulk kept for back-compat. - computers plugin: implements get_collector_schema (identityfield hostname) and apply_collector_payload (create-or-update Asset+Computer, serialnumber, loggedinuser, lastboottime, primary IP communication, installed apps). - tests: 7 collector-contract tests (auth, 404, validation, create/idempotent update, per-plugin key precedence, JWT schema listing). A single dynamic dispatch route is used instead of per-plugin blueprint registration, avoiding Flask's register-blueprint-after-first-request error. 144 tests pass, naming/style check green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
0
tests/test_core/__init__.py
Normal file
0
tests/test_core/__init__.py
Normal file
117
tests/test_core/test_collector_contract.py
Normal file
117
tests/test_core/test_collector_contract.py
Normal file
@@ -0,0 +1,117 @@
|
||||
"""Tests for the generic plugin collector contract (ADR-006).
|
||||
|
||||
Covers the auto-dispatched /api/collector/<plugin> endpoint, per-plugin API
|
||||
key auth, idempotent upsert by identity field, and the JWT-protected
|
||||
/api/collector/_schemas listing.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
KEY = 'testcollectorkey'
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def collector_key(app):
|
||||
"""Set the shared collector API key for the test."""
|
||||
old = app.config.get('COLLECTOR_API_KEY')
|
||||
app.config['COLLECTOR_API_KEY'] = KEY
|
||||
yield KEY
|
||||
app.config['COLLECTOR_API_KEY'] = old
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def computer_assettype(db):
|
||||
"""Seed the computer asset type needed for create-on-ingest."""
|
||||
from shopdb.core.models import AssetType
|
||||
|
||||
at = AssetType(assettype='computer', pluginname='computers',
|
||||
tablename='computers', description='PCs')
|
||||
db.session.add(at)
|
||||
db.session.commit()
|
||||
return at
|
||||
|
||||
|
||||
def test_schemas_requires_jwt(client, db):
|
||||
"""The schema listing rejects unauthenticated callers."""
|
||||
response = client.get('/api/collector/_schemas')
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_schemas_lists_computers(client, db, auth_headers):
|
||||
"""Computers plugin exposes a collector schema keyed by hostname."""
|
||||
response = client.get('/api/collector/_schemas', headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
schemas = response.get_json()['data']['schemas']
|
||||
assert 'computers' in schemas
|
||||
assert schemas['computers']['identityfield'] == 'hostname'
|
||||
|
||||
|
||||
def test_missing_key_rejected(client, db, collector_key, computer_assettype):
|
||||
"""No API key -> 401."""
|
||||
response = client.post('/api/collector/computers',
|
||||
json={'hostname': 'WJPC001'})
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_unknown_plugin_404(client, db, collector_key):
|
||||
"""A plugin with no collector schema returns 404."""
|
||||
response = client.post('/api/collector/nosuchplugin',
|
||||
json={'hostname': 'x'},
|
||||
headers={'X-API-Key': KEY})
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_missing_identity_rejected(client, db, collector_key, computer_assettype):
|
||||
"""Missing the identity field is a validation error."""
|
||||
response = client.post('/api/collector/computers',
|
||||
json={'currentuser': 'someone'},
|
||||
headers={'X-API-Key': KEY})
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
def test_create_then_idempotent_update(client, db, collector_key,
|
||||
computer_assettype):
|
||||
"""First post creates; second post with same hostname updates (no dup)."""
|
||||
from plugins.computers.models import Computer
|
||||
|
||||
payload = {'hostname': 'WJPC100', 'currentuser': 'alice',
|
||||
'serialnumber': 'SN-100'}
|
||||
|
||||
first = client.post('/api/collector/computers', json=payload,
|
||||
headers={'X-API-Key': KEY})
|
||||
assert first.status_code == 200, first.get_json()
|
||||
data = first.get_json()['data']
|
||||
assert data['action'] == 'created'
|
||||
assert data['assetid'] is not None
|
||||
assert data['identityvalue'] == 'WJPC100'
|
||||
|
||||
payload['currentuser'] = 'bob'
|
||||
second = client.post('/api/collector/computers', json=payload,
|
||||
headers={'X-API-Key': KEY})
|
||||
assert second.status_code == 200
|
||||
assert second.get_json()['data']['action'] == 'updated'
|
||||
|
||||
with client.application.app_context():
|
||||
comps = Computer.query.filter(Computer.hostname.ilike('WJPC100')).all()
|
||||
assert len(comps) == 1
|
||||
assert comps[0].loggedinuser == 'bob'
|
||||
|
||||
|
||||
def test_per_plugin_key_overrides_shared(client, db, app, computer_assettype):
|
||||
"""COLLECTOR_API_KEY_<PLUGIN> takes precedence over the shared key."""
|
||||
app.config['COLLECTOR_API_KEY'] = 'sharedkey'
|
||||
app.config['COLLECTOR_API_KEY_COMPUTERS'] = 'computerskey'
|
||||
try:
|
||||
# Shared key now rejected for this plugin.
|
||||
rejected = client.post('/api/collector/computers',
|
||||
json={'hostname': 'WJPC200'},
|
||||
headers={'X-API-Key': 'sharedkey'})
|
||||
assert rejected.status_code == 401
|
||||
|
||||
accepted = client.post('/api/collector/computers',
|
||||
json={'hostname': 'WJPC200'},
|
||||
headers={'X-API-Key': 'computerskey'})
|
||||
assert accepted.status_code == 200
|
||||
finally:
|
||||
app.config.pop('COLLECTOR_API_KEY_COMPUTERS', None)
|
||||
app.config['COLLECTOR_API_KEY'] = None
|
||||
Reference in New Issue
Block a user