Bring the wilm branch into main: endpoints plugin, agent, policies, CSV import
Ports the corporate-GitHub wilm branch onto main, keeping the two branches
separate. Thirteen upstream commits arrive with their authorship intact; the
review fixes and the portability work below are on top.
What arrives:
endpoints plugin - a ShopDB agent reporting endpoint state, telemetry,
services and interfaces, with a command channel the agent
long-polls. Ingest stays on the generic collector route
(ADR-006); the agent key is a UI-managed single-scope
token whose revocation is an instant kill switch.
computers policies - Configuration and Business Policies (RMM-style), scope
trees validated at write time, actions checked against
the endpoints command allowlist. Feature-gated: every
route 404s while the setting is off, so enabling it can
never half-expose the surface.
asset CSV import - templates, a collector script carrying the site path, and
a validate-then-commit import. A plugin can own a virtual
column plus a post-write row hook, which is how a PC's
machine number becomes a controls link without a
hand-built relationships sheet.
agent/ - the Windows agent and its installer.
Contract 0.21.0 -> 0.24.0, additive throughout: register_import_hook (0.22),
mint/list/revoke_service_token (0.23), collector_authorized (0.24). The stability
table had no rows for any of them and now does.
MySQL 5.6 portability. The branch stored three policy columns as sa.JSON, the
only JSON columns in the product, and 5.6 has no JSON type: the migration failed
with a bare syntax error near the word JSON. They are TEXT now, through a
JSONText type decorator, so all seventeen call sites still read and write dicts.
The convention elsewhere is already TEXT (guides.blocksjson,
geenforce.manifestjson).
Review fixes carried on top:
- six policy GETs were jwt_required(optional=True), which authenticates a
caller without checking anything; they require computers.view now, and six
redundant jwt_required decorators are gone
- the agent installer took a default BaseUrl and CollectorKey, so a mistyped
run silently pointed an endpoint at the wrong instance; both are mandatory
and a non-https BaseUrl throws unless -AllowInsecureHttp is passed
- a committing CSV import wrote no audit row
Documentation: the 34 new routes are described in docs/api-inventory.json, which
is hand-maintained on purpose, and the generated spec now covers 331 paths and
477 operations across 22 surfaces. DEVELOPMENT-SETUP's 1071 troubleshooting row
said "use MySQL 8 for dev" and now gives the actual fix, including the trap that
5.6 downgrades ROW_FORMAT=DYNAMIC to Compact with a warning instead of an error.
Full suite 2123 passed, vitest 260 passed, all seventeen plugin chains migrate
clean on MySQL 5.6 and the naming, docs, parity and read-surface gates are green.
This commit is contained in:
187
tests/test_plugins/test_computers_import_machine_link.py
Normal file
187
tests/test_plugins/test_computers_import_machine_link.py
Normal file
@@ -0,0 +1,187 @@
|
||||
"""computers.csv can carry the machine it drives, and the link is built on import.
|
||||
|
||||
A site with a spreadsheet knows "this PC drives machine CNC-014" and wants to
|
||||
write that in the PC row, not hand-build an assetrelationships sheet keyed on
|
||||
ids it never sees. The importer accepts a virtual `machineassetnumber` column,
|
||||
keeps it out of the computers write, and hands it to the computers plugin after
|
||||
each PC row is written. The rules that matter for a cold user driving this off a
|
||||
spreadsheet:
|
||||
|
||||
- a good number builds the PC-controls-machine link;
|
||||
- a number that has no machine, names this PC itself, names a location-only
|
||||
marker, or is claimed twice in one file, is an AMBER warning and is skipped -
|
||||
the PC still imports, the file is still ok;
|
||||
- a dry run builds nothing and, critically, sends no alert;
|
||||
- re-running the same file does not duplicate the link.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from shopdb.core.services.csvimport import (
|
||||
Resolver, import_csv, virtual_columns_for,
|
||||
)
|
||||
from shopdb.extensions import db as _db
|
||||
|
||||
|
||||
REFERENCE = [
|
||||
('assettypes', 'assettype\nComputer\nMachine\n'),
|
||||
('assetstatuses', 'status\nIn Use\n'),
|
||||
('locationtypes', 'locationtype\nBay\n'),
|
||||
]
|
||||
|
||||
|
||||
def _seed(resolver):
|
||||
"""Reference rows, a location, and the 'controls' relationship type."""
|
||||
from shopdb.core.models.relationship import RelationshipType
|
||||
|
||||
for table, text in REFERENCE:
|
||||
result = import_csv(table, text, resolver=resolver, commit=True)
|
||||
assert result.ok, [str(p) for p in result.problems]
|
||||
result = import_csv(
|
||||
'locations', 'locationname,locationtypeid\nBay 3,Bay\n',
|
||||
resolver=resolver, commit=True)
|
||||
assert result.ok, [str(p) for p in result.problems]
|
||||
if not RelationshipType.query.filter_by(relationshiptype='controls').first():
|
||||
_db.session.add(RelationshipType(relationshiptype='controls'))
|
||||
# import_csv writes into the session but never commits; commit the setup so
|
||||
# a dry-run import's rollback only unwinds the dry run, not the fixtures.
|
||||
_db.session.commit()
|
||||
|
||||
|
||||
def _assets(resolver, *rows):
|
||||
"""Create the given assets. Each row is (assetnumber, assettype)."""
|
||||
body = 'assetnumber,assettypeid,statusid,locationid\n'
|
||||
body += ''.join('%s,%s,In Use,Bay 3\n' % r for r in rows)
|
||||
result = import_csv('assets', body, resolver=resolver, commit=True)
|
||||
assert result.ok, [str(p) for p in result.problems]
|
||||
_db.session.commit()
|
||||
|
||||
|
||||
def _import_computers(resolver, body, commit=True):
|
||||
return import_csv('computers', body, resolver=resolver, commit=commit)
|
||||
|
||||
|
||||
def _controls_target(pc_assetnumber):
|
||||
"""Asset number of the machine this PC actively controls, or None."""
|
||||
from shopdb.core.models import Asset
|
||||
from shopdb.core.models.relationship import RelationshipType, AssetRelationship
|
||||
|
||||
pc = Asset.query.filter_by(assetnumber=pc_assetnumber).first()
|
||||
controls = RelationshipType.query.filter_by(relationshiptype='controls').first()
|
||||
rel = AssetRelationship.query.filter(
|
||||
AssetRelationship.sourceassetid == pc.assetid,
|
||||
AssetRelationship.relationshiptypeid == controls.relationshiptypeid,
|
||||
AssetRelationship.isactive.is_(True)).first()
|
||||
if rel is None:
|
||||
return None
|
||||
return _db.session.get(Asset, rel.targetassetid).assetnumber
|
||||
|
||||
|
||||
def test_the_column_is_virtual_and_owned_by_the_computers_plugin(db):
|
||||
"""The plugin registers machineassetnumber; core stays ignorant of it."""
|
||||
assert 'machineassetnumber' in virtual_columns_for('computers')
|
||||
|
||||
|
||||
def test_a_good_machine_number_builds_the_link(db):
|
||||
resolver = Resolver()
|
||||
_seed(resolver)
|
||||
_assets(resolver, ('PC-001', 'Computer'), ('CNC-014', 'Machine'))
|
||||
result = _import_computers(
|
||||
resolver,
|
||||
'assetid,hostname,machineassetnumber\nPC-001,WS-PC-001,CNC-014\n')
|
||||
assert result.ok, [str(p) for p in result.problems]
|
||||
assert result.warnings == []
|
||||
assert _controls_target('PC-001') == 'CNC-014'
|
||||
|
||||
|
||||
def test_an_empty_machine_number_links_nothing_and_does_not_warn(db):
|
||||
resolver = Resolver()
|
||||
_seed(resolver)
|
||||
_assets(resolver, ('PC-001', 'Computer'))
|
||||
result = _import_computers(
|
||||
resolver, 'assetid,hostname,machineassetnumber\nPC-001,WS-PC-001,\n')
|
||||
assert result.ok
|
||||
assert result.warnings == []
|
||||
assert _controls_target('PC-001') is None
|
||||
|
||||
|
||||
def test_a_machine_number_with_no_machine_warns_and_skips(db):
|
||||
"""The PC still imports; the unresolved number is amber, not a failed file."""
|
||||
resolver = Resolver()
|
||||
_seed(resolver)
|
||||
_assets(resolver, ('PC-001', 'Computer'))
|
||||
result = _import_computers(
|
||||
resolver, 'assetid,hostname,machineassetnumber\nPC-001,WS-PC-001,NOPE-99\n')
|
||||
assert result.ok, 'an unresolved machine number must not fail the file'
|
||||
assert len(result.warnings) == 1
|
||||
warning = result.warnings[0]
|
||||
assert warning.column == 'machineassetnumber'
|
||||
assert warning.target == 'NOPE-99'
|
||||
assert warning.action, 'a warning must carry the one corrective step'
|
||||
assert _controls_target('PC-001') is None
|
||||
|
||||
|
||||
def test_a_pc_naming_its_own_asset_number_is_not_self_linked(db):
|
||||
resolver = Resolver()
|
||||
_seed(resolver)
|
||||
_assets(resolver, ('PC-001', 'Computer'))
|
||||
result = _import_computers(
|
||||
resolver, 'assetid,hostname,machineassetnumber\nPC-001,WS-PC-001,PC-001\n')
|
||||
assert result.ok
|
||||
assert len(result.warnings) == 1
|
||||
assert 'own asset number' in result.warnings[0].reason
|
||||
assert _controls_target('PC-001') is None
|
||||
|
||||
|
||||
def test_two_rows_claiming_one_machine_link_only_the_first(db):
|
||||
"""The in-file fan-out guard: a copied cell must not steal the bay."""
|
||||
resolver = Resolver()
|
||||
_seed(resolver)
|
||||
_assets(resolver,
|
||||
('PC-001', 'Computer'), ('PC-002', 'Computer'), ('CNC-014', 'Machine'))
|
||||
result = _import_computers(
|
||||
resolver,
|
||||
'assetid,hostname,machineassetnumber\n'
|
||||
'PC-001,WS-PC-001,CNC-014\n'
|
||||
'PC-002,WS-PC-002,CNC-014\n')
|
||||
assert result.ok
|
||||
assert len(result.warnings) == 1
|
||||
assert 'already claimed' in result.warnings[0].reason
|
||||
assert _controls_target('PC-001') == 'CNC-014'
|
||||
assert _controls_target('PC-002') is None
|
||||
|
||||
|
||||
def test_a_dry_run_builds_no_link_and_leaves_no_trace(db):
|
||||
resolver = Resolver()
|
||||
_seed(resolver)
|
||||
_assets(resolver, ('PC-001', 'Computer'), ('CNC-014', 'Machine'))
|
||||
result = _import_computers(
|
||||
resolver,
|
||||
'assetid,hostname,machineassetnumber\nPC-001,WS-PC-001,CNC-014\n',
|
||||
commit=False)
|
||||
assert result.ok
|
||||
assert result.created == 1
|
||||
_db.session.rollback()
|
||||
assert _controls_target('PC-001') is None
|
||||
|
||||
|
||||
def test_reimporting_the_same_link_does_not_duplicate(db):
|
||||
resolver = Resolver()
|
||||
_seed(resolver)
|
||||
_assets(resolver, ('PC-001', 'Computer'), ('CNC-014', 'Machine'))
|
||||
body = 'assetid,hostname,machineassetnumber\nPC-001,WS-PC-001,CNC-014\n'
|
||||
first = _import_computers(resolver, body)
|
||||
assert first.ok and first.warnings == []
|
||||
|
||||
second = _import_computers(resolver, body)
|
||||
assert second.ok and second.warnings == []
|
||||
assert _controls_target('PC-001') == 'CNC-014'
|
||||
|
||||
from shopdb.core.models import Asset
|
||||
from shopdb.core.models.relationship import RelationshipType, AssetRelationship
|
||||
pc = Asset.query.filter_by(assetnumber='PC-001').first()
|
||||
controls = RelationshipType.query.filter_by(relationshiptype='controls').first()
|
||||
active = AssetRelationship.query.filter(
|
||||
AssetRelationship.sourceassetid == pc.assetid,
|
||||
AssetRelationship.relationshiptypeid == controls.relationshiptypeid,
|
||||
AssetRelationship.isactive.is_(True)).count()
|
||||
assert active == 1, 'a re-import must not stack a second active link'
|
||||
Reference in New Issue
Block a user