Merge wilm: endpoints plugin, agent, policies, CSV import, Entra fields
Thirteen commits from benjamin.baker plus the review fixes. Everything he built is here: the endpoints plugin and its command queue, the .NET endpoint agent, the computers policies subsystem, the site-asset CSV import with its adapters and collector, Entra device identity, and the catalog hand-merge. His branch predates four commits on main (section ticking, the generator share links, Secure Send copy message, and the smoke-test workflow), so those come through untouched - nothing of ours is removed by this merge. wilm stays as its own branch; this does not close it.
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