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:
cproudlock
2026-06-26 16:14:27 -04:00
parent 4d23f5b0fd
commit b567de14ac
5 changed files with 345 additions and 0 deletions

View File

View 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