Make main the lean product a site receives

main was 16 days and 259 commits behind the line the product is actually built
on, at contract 0.30.0 against 0.35.0, with no per-PC credential code at all. It
was neither current nor curated - just stale. This brings it forward and, at the
same time, makes it mean something: main is what a receiving site installs, and
nothing else.

WHAT IT CARRIES. Twelve plugins - backups, computers, endpoints, guides,
knowledgebase, machines, network, notifications, printers, slides, tools,
warranty - plus applications, which is manifest-less core and always ships. The
only manifest dependency inside that set is endpoints on computers, and both are
in it, so the closure is self-contained.

WHAT IT DOES NOT. employees, geenforce, measuringtools, printedparts and usb
stay on the site line. So do the Windows installer tree, the VM rehearsal
helpers, the agent tooling config, one site's data loader, the internal audit
and findings records, and the planner spreadsheet. 1,135 tracked files against
1,438.

A REMOVAL THAT BREAKS THE APP IS NOT A REMOVAL, and two nearly shipped:

deploy/site-profile-universal.json still named all seventeen plugins. It is the
file docs/DEPLOY.md tells a site to provision from, so a fresh install would
have failed at `flask plugin apply-profile` with PluginError - the first thing a
receiving site does, broken by the branch meant to serve them. It now names the
twelve that exist, and its comment says the two must agree.

agent/ was never refreshed. Whoever populated this branch copied shopdb/,
plugins/, frontend/ and tests/ from the site line and left the agent tree at its
September state: 22 C# and PowerShell sources missing, including AgentCredentials,
EnrollmentService, AgentHttpFactory and the fleet-alert client, plus one file the
site line had deleted still present. The old tree is internally consistent, so it
compiles, and nothing in CI builds it - every gate was green over a server that
speaks enrollment and per-PC credentials to a bundled agent source that cannot.
Resynced whole.

DECISIONS THIS ENCODES, rather than defers:

- The plugin-authoring lab builds printedparts, whose manifest depends on
  employees, so it could not stay. The lab remains on the site line and main
  ships no build-along tutorial. That is a real loss for an adopting developer
  and is recorded here rather than discovered later.
- Recognition, Training and Recertification notifications are no longer seeded.
  Each splits one notification into a card per employee and renders a photo,
  which needs a plugin this branch does not bundle. Seeding them would offer an
  author a type that cannot resolve anybody. A site that installs employees gets
  them from that plugin's own seed.
- Windows/IIS is documented as the legacy path throughout, and the README leads
  with Docker, because that is what a receiving site gets.

The API description is regenerated for THIS branch - 347 paths and 483
operations, down from 415 and 573 - so the published contract describes what is
here rather than what the site line serves. docs/CONFIG.md lost the two
measuring-tool label keys the code no longer declares, which two lanes had
decided opposite ways.

Suite: 3124 passed, 6 skipped. Naming and style gates pass. The docs gates run
rather than skip, and every index line count is derived from the file it names.
This commit is contained in:
cproudlock
2026-09-19 11:24:40 -04:00
parent cb3975049c
commit 534f179ee6
675 changed files with 115759 additions and 60239 deletions

View File

@@ -77,18 +77,6 @@ def _marker(db, assetnumber):
return asset
def _tool(db, assetnumber):
"""A measuring tool the collector did NOT create."""
from plugins.measuringtools.models import MeasuringTool
at = AssetType.query.filter_by(assettype='measuring_tool').first()
asset = Asset(assetnumber=assetnumber, assettypeid=at.assettypeid)
db.session.add(asset)
db.session.flush()
db.session.add(MeasuringTool(assetid=asset.assetid))
db.session.commit()
return asset
def _controlled(pcname, label):
"""Asset numbers this PC controls under a collector label."""
pc = Asset.query.filter(Asset.assetnumber.ilike(pcname)).first()
@@ -244,35 +232,12 @@ def test_repeat_cycles_with_the_file_are_stable(client, db, rig, collector_key):
assert _controlled('FMARK700', 'collector:partmarker') == ['PM-0618-A']
# ------------------------------------------------------------ measuring tools
def test_the_same_file_serves_a_metrology_bay(client, db, rig, collector_key):
"""One file, no device type in it: the pc-type decides which sync uses it."""
_tool(db, 'MT-9001')
resp = _report(client, collector_key, 'KEYENCE100',
pctype='gea-shopfloor-keyence', deviceid='MT-9001')
assert resp.status_code in (200, 201), resp.get_data(as_text=True)[:300]
assert _controlled('KEYENCE100', 'collector:measuringtool') == ['MT-9001']
assert Asset.query.filter_by(assetnumber='KEYENCE100-KEYENCE').first() is None
def test_the_0120_field_name_still_works(client, db, rig, collector_key):
"""measuringtool-id.txt shipped in 0.12.0; a staged bay keeps reporting."""
_tool(db, 'MT-9002')
resp = _report(client, collector_key, 'KEYENCE200',
pctype='gea-shopfloor-keyence', measuringtoolid='MT-9002')
assert resp.status_code in (200, 201)
assert _controlled('KEYENCE200', 'collector:measuringtool') == ['MT-9002']
def test_deviceid_wins_when_both_arrive(client, db, rig, collector_key):
_tool(db, 'MT-9003')
_tool(db, 'MT-9004')
resp = _report(client, collector_key, 'KEYENCE300',
pctype='gea-shopfloor-keyence',
deviceid='MT-9003', measuringtoolid='MT-9004')
assert resp.status_code in (200, 201)
assert _controlled('KEYENCE300', 'collector:measuringtool') == ['MT-9003']
# Measuring tools had a section here. No measuringtools plugin ships on this
# line, so the collector's guarded import fails and no instrument asset or
# collector:measuringtool link is ever created - see the comment in
# plugins/computers/plugin.py at that import. The DEVICE-ID handling the section
# exercised is not metrology-specific and is covered by the part-marker cases
# above and below.
def test_a_dormant_challenger_is_not_promoted_when_its_file_disappears(
@@ -297,30 +262,3 @@ def test_a_dormant_challenger_is_not_promoted_when_its_file_disappears(
assert _controlled('FMARK100', 'collector:partmarker') == ['PM-0613-A']
assert _controlled('FMARK200', 'collector:partmarker') == []
def test_an_incumbent_under_another_collector_label_is_still_seen(
client, db, rig, collector_key):
"""On a CMM the instrument IS the bay, so the incumbent's link is the
machine sync's row under collector:machine. Counting only our own label
left two live holders of one instrument, each invisible to the other.
"""
_tool(db, 'CMM4')
# A LIVE incumbent: it reported through the collector, so it has a computer
# row and a recent check-in. A bare asset would correctly be treated as
# yielded, since something that cannot report cannot be holding anything.
_report(client, collector_key, 'WCMM100',
pctype='gea-shopfloor-keyence', deviceid='CMM4')
# Relabel its link to the machine sync's origin, which is the shape a CMM
# really has: there the instrument IS the reported bay.
pc = Asset.query.filter(Asset.assetnumber.ilike('WCMM100')).first()
held = AssetRelationship.query.filter_by(sourceassetid=pc.assetid).first()
held.label = 'collector:machine'
db.session.commit()
resp = _report(client, collector_key, 'WCMM200',
pctype='gea-shopfloor-keyence', deviceid='CMM4')
assert _controlled('WCMM200', 'collector:measuringtool') == []
warnings = ' '.join(resp.get_json()['data'].get('warnings', []))
assert 'CMM4' in warnings

View File

@@ -1,232 +0,0 @@
"""The collector ADOPTS an existing instrument instead of minting a twin.
Minting derived the asset number from the HOSTNAME, so a permanent instrument
inherited the identity of whichever PC drove it that week. And because the
collector keyed idempotency on its OWN label, it could not see a tool it had not
created - on prod that left 43 legacy MT-#### tools shadowed by minted
<HOST>-CMM twins, three records deep for some CMMs.
"""
import pytest
from shopdb.core.models import Asset, AssetType
from shopdb.core.models.relationship import RelationshipType, AssetRelationship
from plugins.measuringtools.models import MeasuringTool
KEY = 'collector-adoption-key'
@pytest.fixture
def collector_key(app):
old = app.config.get('COLLECTOR_API_KEY')
app.config['COLLECTOR_API_KEY'] = KEY
yield KEY
app.config['COLLECTOR_API_KEY'] = old
@pytest.fixture
def rig(db):
"""Asset types, a controls relationship type, and nothing else."""
for name in ('computer', 'measuring_tool'):
if not AssetType.query.filter_by(assettype=name).first():
db.session.add(AssetType(assettype=name))
if not RelationshipType.query.filter_by(relationshiptype='controls').first():
db.session.add(RelationshipType(relationshiptype='controls'))
db.session.commit()
def _tool(db, assetnumber):
"""A measuring tool asset the collector did NOT create."""
assettype = AssetType.query.filter_by(assettype='measuring_tool').first()
asset = Asset(assetnumber=assetnumber, assettypeid=assettype.assettypeid)
db.session.add(asset)
db.session.flush()
db.session.add(MeasuringTool(assetid=asset.assetid))
db.session.commit()
return asset
def _report(client, key, hostname, **extra):
payload = {'hostname': hostname, 'pctype': 'gea-shopfloor-cmm'}
payload.update(extra)
return client.post('/api/collector/computers', json=payload,
headers={'X-API-Key': key})
def _tools_controlled(hostname):
pc = Asset.query.filter_by(assetnumber=hostname).first()
controls = RelationshipType.query.filter_by(relationshiptype='controls').first()
toolids = {r.assetid for r in MeasuringTool.query.with_entities(MeasuringTool.assetid)}
rows = AssetRelationship.query.filter(
AssetRelationship.sourceassetid == pc.assetid,
AssetRelationship.relationshiptypeid == controls.relationshiptypeid,
AssetRelationship.isactive.is_(True)).all()
return [Asset.query.get(r.targetassetid).assetnumber
for r in rows if r.targetassetid in toolids]
def test_a_named_instrument_is_adopted_not_minted(client, db, rig, collector_key):
"""measuringtool-id.txt names the instrument; nothing new is created."""
_tool(db, 'MT-6227')
assert _report(client, collector_key, 'FB7FB1V3',
measuringtoolid='MT-6227').status_code in (200, 201)
assert _tools_controlled('FB7FB1V3') == ['MT-6227']
assert Asset.query.filter_by(assetnumber='FB7FB1V3-CMM').first() is None
def test_a_tool_already_controlled_is_adopted(client, db, rig, collector_key):
"""The legacy MT-#### case: linked by the import, invisible to the collector.
The link must pre-date the first report - that is the real sequence, and it
is the whole point. Reporting first would mint the twin before adoption ever
had a chance, which is exactly the bug.
"""
from plugins.computers.models import Computer
tool = _tool(db, 'MT-6224')
computertype = AssetType.query.filter_by(assettype='computer').first()
pc = Asset(assetnumber='FJPX1GT3', assettypeid=computertype.assettypeid)
db.session.add(pc)
db.session.flush()
db.session.add(Computer(assetid=pc.assetid, hostname='FJPX1GT3'))
controls = RelationshipType.query.filter_by(relationshiptype='controls').first()
db.session.add(AssetRelationship(
sourceassetid=pc.assetid, targetassetid=tool.assetid,
relationshiptypeid=controls.relationshiptypeid, label=None))
db.session.commit()
assert _report(client, collector_key, 'FJPX1GT3').status_code in (200, 201)
assert _tools_controlled('FJPX1GT3') == ['MT-6224']
assert Asset.query.filter_by(assetnumber='FJPX1GT3-CMM').first() is None
def test_the_reported_bay_id_is_adopted(client, db, rig, collector_key):
"""The CMM case: cmmid.txt already reports CMM4, which is a real asset."""
_tool(db, 'CMM4')
assert _report(client, collector_key, 'FB7FB1V3',
machinenumber='CMM4').status_code in (200, 201)
assert 'CMM4' in _tools_controlled('FB7FB1V3')
assert Asset.query.filter_by(assetnumber='FB7FB1V3-CMM').first() is None
def test_minting_still_happens_when_nothing_exists(client, db, rig, collector_key):
"""A genuinely new instrument has no identity to adopt - last resort."""
assert _report(client, collector_key, 'FNEWPC01').status_code in (200, 201)
assert _tools_controlled('FNEWPC01') == ['FNEWPC01-CMM']
def test_a_named_instrument_that_does_not_exist_warns_and_mints_nothing_wrong(
client, db, rig, collector_key):
"""A typo must not mint a phantom instrument nobody can account for."""
resp = _report(client, collector_key, 'FTYPO001', measuringtoolid='MT-NOPE')
assert resp.status_code in (200, 201)
body = resp.get_json()['data']
assert any('MT-NOPE' in w for w in body.get('warnings', []))
def test_adoption_is_idempotent(client, db, rig, collector_key):
_tool(db, 'MT-6331')
for _ in range(3):
_report(client, collector_key, 'F9LQSDB4', measuringtoolid='MT-6331')
assert _tools_controlled('F9LQSDB4') == ['MT-6331']
def test_a_named_instrument_supersedes_a_minted_twin(
client, db, rig, collector_key):
"""The migration case: the twin already exists when the file names the real
one.
This is the state prod is actually in - 43 minted twins already linked - so
adoption that only works on a clean PC fixes nothing. The twin's link has to
be archived, or the PC reads as controlling two instruments.
"""
_tool(db, 'MT-6227')
_report(client, collector_key, 'FB7FB1V3')
assert _tools_controlled('FB7FB1V3') == ['FB7FB1V3-CMM']
_report(client, collector_key, 'FB7FB1V3', measuringtoolid='MT-6227')
assert _tools_controlled('FB7FB1V3') == ['MT-6227']
def test_a_named_id_that_is_not_a_measuring_tool_is_refused(
client, db, rig, collector_key):
"""A machine number in measuringtool-id.txt must not link the machine.
The name resolving is not enough: linked under the measuring-tool label it
would read as an instrument everywhere downstream, on a row the machine sync
also owns.
"""
machinetype = AssetType.query.filter_by(assettype='machine').first()
if machinetype is None:
machinetype = AssetType(assettype='machine')
db.session.add(machinetype)
db.session.flush()
db.session.add(Asset(assetnumber='2335', assettypeid=machinetype.assettypeid))
db.session.commit()
resp = _report(client, collector_key, 'FTYPO777', measuringtoolid='2335')
assert resp.status_code in (200, 201)
body = resp.get_json()['data']
assert any('2335' in w for w in body.get('warnings', []))
assert '2335' not in _all_controlled('FTYPO777')
def test_a_cmm_reporting_its_bay_survives_a_second_cycle(
client, db, rig, collector_key):
"""The instrument IS the reported bay, so both syncs want the same triple.
Adoption used to relabel the machine sync's row. The machine sync finds its
rows BY that label, so cycle 2 built a second row for the same
(pc, tool, controls) triple and MySQL rejected it on the unique constraint:
200, then 500 on every report after. The machine link must survive as a
machine link.
"""
_tool(db, 'CMM4')
for _ in range(3):
resp = _report(client, collector_key, 'FB7FB1V3', machinenumber='CMM4')
assert resp.status_code in (200, 201)
assert _all_controlled('FB7FB1V3') == ['CMM4']
assert Asset.query.filter_by(assetnumber='FB7FB1V3-CMM').first() is None
pc = Asset.query.filter_by(assetnumber='FB7FB1V3').first()
tool = Asset.query.filter_by(assetnumber='CMM4').first()
rows = AssetRelationship.query.filter_by(
sourceassetid=pc.assetid, targetassetid=tool.assetid).all()
assert len(rows) == 1
assert rows[0].label == 'collector:machine'
def test_a_reported_identifier_is_not_a_wildcard(client, db, rig, collector_key):
"""`_` and `%` in a reported identifier must match themselves, nothing else.
These values come from a text file on a shopfloor PC. Resolved with ilike,
'MT-600_' matched MT-6001 and a bare '%' matched whatever active asset came
first - an identifier resolving to an asset it does not name, then linked to
as though it did.
"""
_tool(db, 'MT-6001')
resp = _report(client, collector_key, 'FWILD001', measuringtoolid='MT-600_')
assert resp.status_code in (200, 201)
assert 'MT-6001' not in _tools_controlled('FWILD001')
resp = _report(client, collector_key, 'FWILD002', measuringtoolid='%')
assert resp.status_code in (200, 201)
assert 'MT-6001' not in _tools_controlled('FWILD002')
def _all_controlled(hostname):
"""Every active controls target, tool or not - the refusal cases need to see
a link the tool-only helper would filter out."""
pc = Asset.query.filter_by(assetnumber=hostname).first()
controls = RelationshipType.query.filter_by(relationshiptype='controls').first()
rows = AssetRelationship.query.filter(
AssetRelationship.sourceassetid == pc.assetid,
AssetRelationship.relationshiptypeid == controls.relationshiptypeid,
AssetRelationship.isactive.is_(True)).all()
targets = [r.targetassetid for r in rows]
if not targets:
return []
return sorted(a.assetnumber for a in
Asset.query.filter(Asset.assetid.in_(targets)).all())

View File

@@ -6,7 +6,7 @@ hand or by the legacy import was invisible and the insert violated the
constraint; and the minted marker's asset number is derived from the PC, so it
could already be taken. Either way the PC reported 200 on the cycle that minted
the marker and 500 on every cycle after - the same shape as the CMM adoption bug
in test_collector_measuringtool_adoption.py.
in the collector contract tests.
"""
import pytest

View File

@@ -0,0 +1,87 @@
"""Scope evaluation for the All PCs policy target.
These are pure unit tests: policyscope has no db or Flask dependency, so the
whole grammar can be exercised with plain dicts. build_endpoint_context needs
only duck-typed stand-ins for a Computer row.
"""
import pytest
from plugins.computers.services.policyresolver import build_endpoint_context
from plugins.computers.services.policyscope import (
ScopeError,
evaluate_scope,
validate_scope,
)
class FakeComputerType:
def __init__(self, icon):
self.icon = icon
class FakeComputer:
"""Minimal stand-in for a Computer row, no db required."""
def __init__(self, computertypeid=None, icon=None, installedapps=()):
self.computertypeid = computertypeid
self.computertype = FakeComputerType(icon) if icon is not None else None
self.installedapps = installedapps
def test_allpcs_is_a_valid_scope_node():
validate_scope({'type': 'allpcs'})
def test_allpcs_matches_an_ordinary_pc():
context = {'computertypeid': 1, 'appids': set(), 'isserver': False}
assert evaluate_scope({'type': 'allpcs'}, context) is True
def test_allpcs_does_not_match_a_server():
"""The whole point of the option: servers are excluded."""
context = {'computertypeid': 4, 'appids': set(), 'isserver': True}
assert evaluate_scope({'type': 'allpcs'}, context) is False
def test_allpcs_matches_an_endpoint_with_no_type_designation():
"""An untyped endpoint is an ordinary PC until something says otherwise."""
context = {'computertypeid': None, 'appids': set()}
assert evaluate_scope({'type': 'allpcs'}, context) is True
def test_true_still_matches_a_server():
"""'true' keeps its old meaning so existing policies do not silently shift."""
context = {'computertypeid': 4, 'appids': set(), 'isserver': True}
assert evaluate_scope({'type': 'true'}, context) is True
def test_allpcs_composes_with_other_nodes():
"""AND of allpcs and an app still excludes a server running that app."""
scope = {
'type': 'and',
'nodes': [{'type': 'allpcs'}, {'type': 'app', 'id': 7}],
}
pc = {'computertypeid': 1, 'appids': {7}, 'isserver': False}
server = {'computertypeid': 4, 'appids': {7}, 'isserver': True}
assert evaluate_scope(scope, pc) is True
assert evaluate_scope(scope, server) is False
def test_context_marks_a_server_icon_type_as_a_server():
computer = FakeComputer(computertypeid=4, icon='server')
assert build_endpoint_context(computer)['isserver'] is True
def test_context_does_not_mark_a_desktop_type_as_a_server():
computer = FakeComputer(computertypeid=1, icon='desktop')
assert build_endpoint_context(computer)['isserver'] is False
def test_context_handles_a_computer_with_no_type():
computer = FakeComputer()
assert build_endpoint_context(computer)['isserver'] is False
def test_an_unknown_node_type_is_still_rejected():
with pytest.raises(ScopeError):
validate_scope({'type': 'allpc'})

View File

@@ -0,0 +1,181 @@
"""Authoring a policy requires the permission to run what the policy carries.
A policy is a deferred instruction to run something. Gating only the enqueue
route (plugins/endpoints/api/routes.py) left authoring wide open: computers.edit
alone could author a policy carrying run_script and let the agent execute it on
every machine in scope, which undoes the whole point of splitting the big four.
These tests pin the four ways a policy's EFFECT can change - authoring actions,
widening scope, reactivating, and composing - so a future edit cannot quietly
reopen any one of them.
"""
from werkzeug.security import generate_password_hash
from shopdb.core.models import Setting
CONFIG_URL = '/api/computers/policies/configuration'
BUSINESS_URL = '/api/computers/policies/business'
def _enable_policies(db):
if not Setting.query.filter_by(key='computers_configpolicies_enabled').first():
db.session.add(Setting(key='computers_configpolicies_enabled',
value='true'))
db.session.commit()
def _user_with_perms(client, db, username, permnames):
"""A non-admin holding exactly permnames. Returns login headers."""
from shopdb.core.models import User, Role, Permission
Permission.seed()
Permission.seed_entries([
('endpoints.runscript', 'Push scripts', 'endpoints'),
('endpoints.restart', 'Remote restart', 'endpoints'),
])
db.session.flush()
role = Role(rolename=username + 'role')
db.session.add(role)
db.session.flush()
role.permissions = Permission.query.filter(
Permission.name.in_(permnames)).all()
user = User(username=username, email=username + '@test.local',
passwordhash=generate_password_hash('testpass'))
user.roles.append(role)
db.session.add(user)
db.session.commit()
login = client.post('/api/auth/login',
json={'username': username, 'password': 'testpass'})
token = login.get_json()['data']['access_token']
return {'Authorization': f'Bearer {token}'}
def _policy_editor(client, db, username='policyeditor', extra=()):
return _user_with_perms(
client, db, username,
['computers.view', 'computers.create', 'computers.edit',
'computers.delete', *extra])
def _make_policy(db, name, action='run_script', scope=None, isactive=True):
from plugins.computers.models import (ConfigurationPolicy,
ConfigurationPolicyAction)
policy = ConfigurationPolicy(name=name, scopejson=scope, isactive=isactive)
db.session.add(policy)
db.session.flush()
db.session.add(ConfigurationPolicyAction(
configurationpolicyid=policy.configurationpolicyid, action=action))
db.session.commit()
return policy.configurationpolicyid
def test_authoring_run_script_needs_the_runscript_permission(client, db):
"""The core escalation: computers.create alone must not mint a policy that
runs arbitrary scripts."""
_enable_policies(db)
headers = _policy_editor(client, db)
response = client.post(CONFIG_URL, headers=headers, json={
'name': 'Sneaky', 'actions': [{'action': 'run_script'}],
})
assert response.status_code == 403, response.get_json()
message = response.get_json()['data']['error']['message']
assert 'endpoints.runscript' in message
def test_authoring_run_script_is_allowed_with_the_permission(client, db):
"""Holding the grant makes it ordinary work, not a special case."""
_enable_policies(db)
headers = _policy_editor(client, db, extra=['endpoints.runscript'])
response = client.post(CONFIG_URL, headers=headers, json={
'name': 'Legitimate', 'actions': [{'action': 'run_script'}],
})
assert response.status_code == 201, response.get_json()
def test_widening_scope_on_an_existing_policy_is_gated(client, db):
"""Scope widening changes what an already-authored action DOES without
touching the action list. Gating the payload alone would miss it."""
_enable_policies(db)
policyid = _make_policy(db, 'Narrow', scope={'type': 'pctype', 'id': 1})
headers = _policy_editor(client, db)
response = client.put(f'{CONFIG_URL}/{policyid}', headers=headers,
json={'scopejson': None})
assert response.status_code == 403, response.get_json()
assert 'endpoints.runscript' in response.get_json()['data']['error']['message']
def test_renaming_a_policy_is_not_gated(client, db):
"""A rename changes nothing that runs. Gating it would be security theatre
that stops people tidying up."""
_enable_policies(db)
policyid = _make_policy(db, 'Old Name')
headers = _policy_editor(client, db)
response = client.put(f'{CONFIG_URL}/{policyid}', headers=headers,
json={'name': 'New Name',
'description': 'clearer wording'})
assert response.status_code == 200, response.get_json()
def test_reactivating_a_policy_is_gated_but_deactivating_is_not(client, db):
"""Reactivation re-arms the action. Deactivation cannot widen anything, and
refusing it would strand a live policy nobody present can switch off."""
_enable_policies(db)
dormant = _make_policy(db, 'Dormant', isactive=False)
live = _make_policy(db, 'Live', isactive=True)
headers = _policy_editor(client, db)
blocked = client.put(f'{CONFIG_URL}/{dormant}', headers=headers,
json={'isactive': True})
assert blocked.status_code == 403, blocked.get_json()
allowed = client.put(f'{CONFIG_URL}/{live}', headers=headers,
json={'isactive': False})
assert allowed.status_code == 200, allowed.get_json()
def test_composing_a_privileged_policy_into_a_business_policy_is_gated(
client, db):
"""Composition is the quiet escalation: attaching a configuration policy
runs its actions without the composer ever naming them."""
_enable_policies(db)
policyid = _make_policy(db, 'Privileged')
headers = _policy_editor(client, db)
response = client.post(BUSINESS_URL, headers=headers, json={
'name': 'Wrapper',
'items': [{'configurationpolicyid': policyid}],
})
assert response.status_code == 403, response.get_json()
assert 'endpoints.runscript' in response.get_json()['data']['error']['message']
def test_deleting_a_policy_carrying_an_elevated_action_is_gated(client, db):
"""Deleting a gate policy REMOVES a restriction, so it changes what
endpoints will run just as surely as authoring one does."""
_enable_policies(db)
policyid = _make_policy(db, 'Restriction')
headers = _policy_editor(client, db)
response = client.delete(f'{CONFIG_URL}/{policyid}', headers=headers)
assert response.status_code == 403, response.get_json()
def test_the_action_catalog_marks_what_the_caller_may_author(client, db):
"""Marked, not hidden. Hiding would make the editor look like the capability
does not exist; marking says it exists and is not yours."""
_enable_policies(db)
headers = _policy_editor(client, db, extra=['endpoints.restart'])
response = client.get('/api/computers/policies/actions', headers=headers)
assert response.status_code == 200, response.get_json()
catalog = {row['action']: row for row in response.get_json()['data']}
assert catalog['system_restart']['mayauthor'] is True
assert catalog['run_script']['mayauthor'] is False
assert catalog['run_script']['requiredpermission'] == 'endpoints.runscript'

View File

@@ -1,212 +0,0 @@
"""Tests for self-hosted employee photo upload, replace, delete, serve, and the
shared photo-URL resolver used by both EmployeeDetail and the kiosk cards.
Covers: admin-gated upload/delete, the public serve route, one-photo-per-person
replace semantics, external-mode 409s, and resolve_employee_photo_url in both
directory modes. The wjf_employees HR DB is not available under test, so external
resolution is exercised by passing the Picture value directly (no query)."""
import io
import os
from shopdb.core.models import Setting
from plugins.employees.models import DirectoryEmployee
from plugins.employees.api.routes import (
resolve_employee_photo_url,
EMPLOYEE_PHOTO_URL_PREFIX,
EMPLOYEE_PHOTO_STATIC_PREFIX,
)
def _set_mode(db, mode):
row = Setting.query.filter_by(key='employee_directory_mode').first()
if row:
row.value = mode
else:
db.session.add(Setting(key='employee_directory_mode', value=mode,
valuetype='string', category='site'))
db.session.commit()
def _make_employee(db, sso=210000001):
emp = DirectoryEmployee(sso=sso, firstname='Test', lastname='Person')
db.session.add(emp)
db.session.commit()
return emp
# --- upload / replace / delete (self-hosted) --------------------------------
def test_upload_forbidden_for_non_admin(client, db, member_headers):
"""A role-less authenticated user cannot upload an employee photo."""
_set_mode(db, 'selfhosted')
emp = _make_employee(db)
data = {'file': (io.BytesIO(b'bytes'), 'p.png')}
resp = client.post(f'/api/employees/{emp.sso}/photo', data=data,
content_type='multipart/form-data', headers=member_headers)
assert resp.status_code == 403
assert resp.get_json()['data']['error']['code'] == 'FORBIDDEN'
def test_upload_sets_photofilename_and_file_exists(client, db, auth_headers, app):
"""Admin upload writes photofilename + photourl and lands the file on disk."""
_set_mode(db, 'selfhosted')
emp = _make_employee(db)
payload = b'\x89PNG\r\n\x1a\nfake-png'
data = {'file': (io.BytesIO(payload), 'p.png')}
resp = client.post(f'/api/employees/{emp.sso}/photo', data=data,
content_type='multipart/form-data', headers=auth_headers)
assert resp.status_code == 200, resp.get_json()
filename = f'photo-{emp.sso}.png'
body = resp.get_json()['data']
assert body['photofilename'] == filename
assert body['photourl'] == f'{EMPLOYEE_PHOTO_URL_PREFIX}{filename}'
refreshed = db.session.get(DirectoryEmployee, emp.sso)
assert refreshed.photofilename == filename
path = os.path.join(app.instance_path, 'employeephotos', filename)
assert os.path.exists(path)
with open(path, 'rb') as handle:
assert handle.read() == payload
def test_upload_rejects_invalid_extension(client, db, auth_headers):
"""A disallowed file extension is rejected."""
_set_mode(db, 'selfhosted')
emp = _make_employee(db)
data = {'file': (io.BytesIO(b'MZ'), 'p.exe')}
resp = client.post(f'/api/employees/{emp.sso}/photo', data=data,
content_type='multipart/form-data', headers=auth_headers)
assert resp.status_code == 400
assert resp.get_json()['data']['error']['code'] == 'VALIDATION_ERROR'
def test_upload_missing_employee_is_404(client, db, auth_headers):
"""Uploading to a nonexistent SSO is a 404."""
_set_mode(db, 'selfhosted')
data = {'file': (io.BytesIO(b'x'), 'p.png')}
resp = client.post('/api/employees/999999/photo', data=data,
content_type='multipart/form-data', headers=auth_headers)
assert resp.status_code == 404
def test_reupload_replaces_and_removes_old_extension(client, db, auth_headers, app):
"""Re-upload with a different extension deletes the prior file."""
_set_mode(db, 'selfhosted')
emp = _make_employee(db)
first = {'file': (io.BytesIO(b'first'), 'p.png')}
client.post(f'/api/employees/{emp.sso}/photo', data=first,
content_type='multipart/form-data', headers=auth_headers)
oldpath = os.path.join(app.instance_path, 'employeephotos', f'photo-{emp.sso}.png')
assert os.path.exists(oldpath)
second = {'file': (io.BytesIO(b'second'), 'p.jpg')}
resp = client.post(f'/api/employees/{emp.sso}/photo', data=second,
content_type='multipart/form-data', headers=auth_headers)
assert resp.status_code == 200
assert not os.path.exists(oldpath)
newpath = os.path.join(app.instance_path, 'employeephotos', f'photo-{emp.sso}.jpg')
assert os.path.exists(newpath)
refreshed = db.session.get(DirectoryEmployee, emp.sso)
assert refreshed.photofilename == f'photo-{emp.sso}.jpg'
def test_serve_returns_bytes(client, db, auth_headers):
"""The public serve route returns the uploaded bytes without auth."""
_set_mode(db, 'selfhosted')
emp = _make_employee(db)
payload = b'\x89PNG\r\n\x1a\nserved'
data = {'file': (io.BytesIO(payload), 'p.png')}
client.post(f'/api/employees/{emp.sso}/photo', data=data,
content_type='multipart/form-data', headers=auth_headers)
served = client.get(f'{EMPLOYEE_PHOTO_URL_PREFIX}photo-{emp.sso}.png', headers=auth_headers)
assert served.status_code == 200
assert served.get_data() == payload
def test_delete_clears_field_and_removes_file(client, db, auth_headers, app):
"""Delete clears photofilename and removes the uploaded file."""
_set_mode(db, 'selfhosted')
emp = _make_employee(db)
data = {'file': (io.BytesIO(b'bytes'), 'p.png')}
client.post(f'/api/employees/{emp.sso}/photo', data=data,
content_type='multipart/form-data', headers=auth_headers)
path = os.path.join(app.instance_path, 'employeephotos', f'photo-{emp.sso}.png')
assert os.path.exists(path)
resp = client.delete(f'/api/employees/{emp.sso}/photo', headers=auth_headers)
assert resp.status_code == 200
assert resp.get_json()['data']['photourl'] is None
assert not os.path.exists(path)
refreshed = db.session.get(DirectoryEmployee, emp.sso)
assert refreshed.photofilename is None
def test_delete_forbidden_for_non_admin(client, db, member_headers):
"""A role-less authenticated user cannot delete an employee photo."""
_set_mode(db, 'selfhosted')
emp = _make_employee(db)
resp = client.delete(f'/api/employees/{emp.sso}/photo', headers=member_headers)
assert resp.status_code == 403
# --- external-mode 409 ------------------------------------------------------
def test_upload_conflict_in_external_mode(client, db, auth_headers):
"""Upload is a 409 when the directory is external (HR owns the photo)."""
_set_mode(db, 'external')
data = {'file': (io.BytesIO(b'x'), 'p.png')}
resp = client.post('/api/employees/210000001/photo', data=data,
content_type='multipart/form-data', headers=auth_headers)
assert resp.status_code == 409
assert resp.get_json()['data']['error']['code'] == 'CONFLICT'
def test_delete_conflict_in_external_mode(client, db, auth_headers):
"""Delete is a 409 when the directory is external."""
_set_mode(db, 'external')
resp = client.delete('/api/employees/210000001/photo', headers=auth_headers)
assert resp.status_code == 409
# --- resolver ---------------------------------------------------------------
def test_resolver_selfhosted_returns_upload_url_or_none(client, db, auth_headers):
"""Self-hosted: served upload URL when a photo exists, else None."""
_set_mode(db, 'selfhosted')
emp = _make_employee(db)
assert resolve_employee_photo_url(emp.sso) is None
data = {'file': (io.BytesIO(b'x'), 'p.png')}
client.post(f'/api/employees/{emp.sso}/photo', data=data,
content_type='multipart/form-data', headers=auth_headers)
assert resolve_employee_photo_url(emp.sso) == f'{EMPLOYEE_PHOTO_URL_PREFIX}photo-{emp.sso}.png'
def test_resolver_external_prefixes_relative_and_passes_urls(db):
"""External: relative HR paths get the static prefix; absolute URLs pass through."""
_set_mode(db, 'external')
assert resolve_employee_photo_url(210000001, 'Support/210000001.png') == \
f'{EMPLOYEE_PHOTO_STATIC_PREFIX}Support/210000001.png'
assert resolve_employee_photo_url(210000001, 'https://hr.example.net/p.png') == \
'https://hr.example.net/p.png'
assert resolve_employee_photo_url(210000001, None) is None
assert resolve_employee_photo_url(None) is None
def test_lookup_includes_photourl(client, db, auth_headers):
"""The lookup serializer carries the resolved photourl (self-hosted)."""
_set_mode(db, 'selfhosted')
emp = _make_employee(db)
data = {'file': (io.BytesIO(b'x'), 'p.png')}
client.post(f'/api/employees/{emp.sso}/photo', data=data,
content_type='multipart/form-data', headers=auth_headers)
resp = client.get(f'/api/employees/lookup/{emp.sso}', headers=auth_headers)
assert resp.status_code == 200
assert resp.get_json()['data']['photourl'] == f'{EMPLOYEE_PHOTO_URL_PREFIX}photo-{emp.sso}.png'

View File

@@ -1,68 +0,0 @@
"""Resolving an SSO says HOW it resolved, or why it did not.
Built after the shopfloor board lost every photo and showed lowercase SSOs
instead of names, and nothing in the system could say why. Both resolvers
returned None on any problem, so an unreachable HR host, a rotated credential
and a genuinely unknown SSO were indistinguishable - and none of them were
logged. This endpoint exists to tell those apart.
"""
URL = '/api/employees/resolve/'
def _selfhosted(db):
from shopdb.core.models import Setting
row = Setting.query.filter_by(key='employee_directory_mode').first()
if row:
row.value = 'selfhosted'
else:
db.session.add(Setting(key='employee_directory_mode', value='selfhosted'))
db.session.commit()
def test_a_known_employee_resolves_and_names_its_source(client, db, auth_headers):
from plugins.employees.models import DirectoryEmployee
_selfhosted(db)
db.session.add(DirectoryEmployee(sso=502123, firstname='Ada',
lastname='Lovelace'))
db.session.commit()
data = client.get(URL + '502123', headers=auth_headers).get_json()['data']
assert data['name'] == 'Ada Lovelace'
assert data['source'] == 'directory'
assert data['mode'] == 'selfhosted'
assert data['error'] is None
def test_an_unknown_sso_says_so_rather_than_returning_nothing(client, db,
auth_headers):
"""The board shows a lowercase SSO for this case AND for a broken
directory. The whole point is that they now read differently."""
_selfhosted(db)
data = client.get(URL + '999999', headers=auth_headers).get_json()['data']
assert data['name'] is None
assert data['error']
def test_a_user_account_answers_when_the_directory_does_not(client, db,
auth_headers):
"""Someone from another site has no directory row but does have a login.
That fallback exists; without this endpoint nothing showed it had fired."""
from shopdb.core.models import User
_selfhosted(db)
db.session.add(User(username='701234', firstname='Grace', lastname='Hopper',
email='g@example.com', passwordhash='x'))
db.session.commit()
data = client.get(URL + '701234', headers=auth_headers).get_json()['data']
assert data['name'] == 'Grace Hopper'
assert data['source'] == 'useraccount'
def test_a_non_numeric_sso_is_rejected_clearly(client, db, auth_headers):
data = client.get(URL + 'abc', headers=auth_headers).get_json()['data']
assert data['error'] == 'SSO must be digits'
def test_the_endpoint_needs_a_login(client, db):
assert client.get(URL + '502123').status_code == 401

View File

@@ -0,0 +1,250 @@
"""Auto-registration of staged agent builds as a pushable Application.
The endpoint update_app push resolves an install source from the applications
table alone; it cannot see the agent version store. Before this, a build could
be staged and current while no application row existed at all, which is exactly
what was found in the fleet - the agent was unpushable.
These tests pin the properties that make the bridge trustworthy:
- staging creates the row, and re-staging updates it in place (never a row
per build, because appname is unique and drift is the failure mode);
- the packaged archive is byte-stable for unchanged input, because update_app
pins a download to a digest and a drifting hash breaks verification;
- every path that changes 'current' re-registers, including rollback;
- registration failure never fails an upload that already landed on disk.
Uploads use PE-magic bytes with a monkeypatched version parser, matching the
approach in test_endpoints_agentupload.py - building a real PE with an
RT_VERSION resource inline is impractical.
Every test runs against an ISOLATED store. create_app('testing') resolves
instance_path to the real repo instance/ directory, so without redirection
these tests stage fake 130-byte binaries into the developer's actual agent
store and leave invented versions behind in its manifest. The isolate fixture
below is autouse for that reason: it is a correctness requirement, not a
convenience.
"""
import hashlib
import io
import os
import zipfile
import pytest
from plugins.endpoints.api import agentstore, agentupload, agentregistry
from shopdb.core.models.application import Application
UPLOAD_URL = '/api/endpoints/siteagent/binaries/{}'
CURRENT_URL = '/api/endpoints/siteagent/binaries/{}/current'
_PE_BYTES = b'MZ' + b'\x00' * 128
@pytest.fixture(autouse=True)
def isolate_store(tmp_path, monkeypatch):
"""Point the agent store and package output at a per-test directory.
Both must be redirected. Redirecting only the store would still write the
~zip into the real applicationpackages tree under a live application id.
"""
siteagent = tmp_path / 'siteagent'
packages = tmp_path / 'applicationpackages'
siteagent.mkdir()
packages.mkdir()
monkeypatch.setattr(agentstore, '_siteagent_dir', lambda: str(siteagent))
monkeypatch.setattr(agentregistry, 'application_package_location',
lambda: (str(packages), '/api/applications/package/'))
return tmp_path
def _upload(client, headers, monkeypatch, version, binkey='agent',
payload=None, makecurrent=None):
"""Stage a build through the real HTTP route."""
monkeypatch.setattr(agentstore, 'read_pe_file_version', lambda data: version)
form = {'file': (io.BytesIO(payload or _PE_BYTES), 'shopdbagent.exe'),
'version': version}
if makecurrent is not None:
form['makecurrent'] = makecurrent
return client.post(UPLOAD_URL.format(binkey), data=form,
content_type='multipart/form-data', headers=headers)
def _agent_row():
return Application.query.filter_by(
appname=agentregistry.AGENT_APPNAME).first()
def test_staging_a_build_creates_a_pushable_application_row(
client, db, app, auth_headers, monkeypatch):
"""Without this row, update_app has nothing to resolve and cannot push."""
assert _agent_row() is None
response = _upload(client, auth_headers, monkeypatch, '1.16.0.0')
assert response.status_code == 200
registration = response.get_json()['data']['registration']
assert registration['ok'] is True
row = _agent_row()
assert row is not None
# The row must be resolvable and pinned: a path update_app can fetch, and a
# digest it can verify the bytes against.
assert row.installpath == '/api/applications/package/{}/{}'.format(
row.appid, agentregistry.AGENT_PACKAGE_FILENAME)
assert row.installersha256 == registration['sha256']
assert row.isinstallable is True
# Hidden: this is machine-managed, and must not appear in the software
# catalogue beside real licensed applications.
assert row.ishidden is True
def test_restaging_updates_the_same_row_rather_than_adding_one(
client, db, app, auth_headers, monkeypatch):
"""appname is unique; a row per build would break the push target."""
_upload(client, auth_headers, monkeypatch, '1.16.0.0')
first = _agent_row()
firstid = first.appid
response = _upload(client, auth_headers, monkeypatch, '1.17.0.0')
assert response.status_code == 200
assert Application.query.filter_by(
appname=agentregistry.AGENT_APPNAME).count() == 1
row = _agent_row()
assert row.appid == firstid
# The row must follow the newly current build, not keep pointing at 1.16.
assert row.installersha256 == \
response.get_json()['data']['registration']['sha256']
def test_packaging_the_same_build_twice_yields_the_same_digest(
client, db, app, auth_headers, monkeypatch):
"""update_app pins bytes by digest.
zipfile stamps members with the current mtime by default, which made the
archive hash change on every call for identical input. That would rewrite
installersha256 out from under an endpoint mid-download and fail its
SHA-256 verification.
"""
_upload(client, auth_headers, monkeypatch, '1.16.0.0')
firstpayload, firstmanifest = agentstore.build_current_package()
secondpayload, secondmanifest = agentstore.build_current_package()
assert firstmanifest['sha256'] == secondmanifest['sha256']
assert firstpayload == secondpayload
def test_the_package_contains_the_staged_binary_under_a_fixed_name(
client, db, app, auth_headers, monkeypatch):
"""The install script addresses members by name, not by discovery."""
_upload(client, auth_headers, monkeypatch, '1.16.0.0')
payload, manifest = agentstore.build_current_package()
with zipfile.ZipFile(io.BytesIO(payload)) as archive:
assert archive.testzip() is None
names = archive.namelist()
assert agentstore.PACKAGE_MEMBER_NAMES['agent'] in names
assert manifest['agentversion'] == '1.16.0.0'
assert manifest['contents']['agent']['version'] == '1.16.0.0'
def test_the_written_package_file_matches_the_recorded_digest(
client, db, app, auth_headers, monkeypatch):
"""A row whose digest disagrees with the file on disk is unpushable."""
_upload(client, auth_headers, monkeypatch, '1.16.0.0')
row = _agent_row()
path = agentregistry._package_path(row.appid)
assert os.path.isfile(path)
with open(path, 'rb') as handle:
ondisk = hashlib.sha256(handle.read()).hexdigest()
assert ondisk == row.installersha256
# The atomic-write temp file must not survive a successful write.
assert not os.path.isfile(path + '.tmp')
def test_rolling_back_repoints_the_row_at_the_restored_build(
client, db, app, auth_headers, monkeypatch):
"""A rollback changes the bytes the fleet should get.
If only upload re-registered, the row would still pin the build the
operator just rolled away from, and the push would ship it anyway.
"""
_upload(client, auth_headers, monkeypatch, '1.16.0.0',
payload=b'MZ' + b'\x01' * 256)
oldsha = _agent_row().installersha256
_upload(client, auth_headers, monkeypatch, '1.17.0.0',
payload=b'MZ' + b'\x02' * 256)
newsha = _agent_row().installersha256
assert newsha != oldsha
response = client.put(CURRENT_URL.format('agent'),
json={'version': '1.16.0.0'}, headers=auth_headers)
assert response.status_code == 200
assert response.get_json()['data']['registration']['ok'] is True
assert agentstore.current_version('agent') == '1.16.0.0'
# Back to the rolled-to build's package, not the newer one.
assert _agent_row().installersha256 == oldsha
def test_a_registration_failure_does_not_fail_the_upload(
client, db, app, auth_headers, monkeypatch):
"""The binary is already staged and audited by the time this runs.
Turning a packaging problem into an error response would tell the operator
the upload was rejected when it actually succeeded, inviting a re-upload of
a build that is already there.
"""
def _explode():
raise OSError('simulated packaging failure')
monkeypatch.setattr(agentregistry.agentstore, 'build_current_package',
_explode)
response = _upload(client, auth_headers, monkeypatch, '1.16.0.0')
assert response.status_code == 200
body = response.get_json()['data']
# The staging itself still stands...
assert body['current'] == '1.16.0.0'
# ...and the failure is surfaced rather than swallowed.
assert body['registration']['ok'] is False
assert 'simulated packaging failure' in body['registration']['error']
def test_staging_only_a_tray_reports_that_there_is_nothing_to_package(
client, db, app, auth_headers, monkeypatch):
"""The bundle is defined by the agent; a tray alone is not pushable."""
response = _upload(client, auth_headers, monkeypatch, '1.6.5.0',
binkey='tray')
assert response.status_code == 200
registration = response.get_json()['data']['registration']
assert registration['ok'] is False
assert 'no current agent build' in registration['error']
assert _agent_row() is None
def test_these_tests_do_not_touch_the_real_agent_store(
client, db, app, auth_headers, monkeypatch, isolate_store):
"""Guard the isolation itself.
An earlier draft of this file wrote fake 130-byte binaries into the repo's
real instance/siteagent/ tree and invented a version in its manifest,
because create_app('testing') shares the developer's instance_path. If that
redirection ever regresses, fail here rather than silently corrupting a
staged build again.
"""
_upload(client, auth_headers, monkeypatch, '9.9.9.9')
# The staged build landed inside the per-test directory...
staged = isolate_store / 'siteagent' / 'agent' / '9.9.9.9'
assert staged.is_dir()
# ...and the real store never saw it.
realstore = os.path.join(app.instance_path, 'siteagent', 'agent', '9.9.9.9')
assert not os.path.exists(realstore)

View File

@@ -0,0 +1,329 @@
"""The agent self-upgrade enqueue route.
Replacing the agent is self-modifying the control plane: the process that
receives the command is the one being swapped. The route therefore sits behind
endpoints.agentupdate, and - more importantly - it does NOT implement its own
enqueue. It prepares the delivery and delegates to enqueue_endpoint_command so
the endpoint lock, the Configuration Policy gate, stale-command reaping and the
at-most-one-in-flight rule all still apply.
These tests pin that delegation. A parallel enqueue path that quietly skipped a
lock or the in-flight rule would be invisible in normal use and would show up
as a fleet-wide agent swap landing on a quarantined machine.
The store is isolated per test: create_app('testing') resolves instance_path to
the real repo instance/, so without redirection these would stage binaries into
the developer's actual agent store.
"""
import os
import tempfile
import pytest
from werkzeug.security import generate_password_hash
from plugins.endpoints.api import agentstore, agentregistry, agentupgradescript
UPGRADE_URL = '/api/endpoints/{}/agentupgrade'
_PE_BYTES = b'MZ' + b'\x00' * 4096
@pytest.fixture(autouse=True)
def isolate_store(tmp_path, monkeypatch):
"""Redirect the agent store, package output and script bodies."""
siteagent = tmp_path / 'siteagent'
packages = tmp_path / 'applicationpackages'
scripts = tmp_path / 'scripts'
for directory in (siteagent, packages, scripts):
directory.mkdir()
monkeypatch.setattr(agentstore, '_siteagent_dir', lambda: str(siteagent))
monkeypatch.setattr(agentregistry, 'application_package_location',
lambda: (str(packages), '/api/applications/package/'))
monkeypatch.setattr(agentupgradescript, 'script_body_location',
lambda: (str(scripts), '/api/scripts/body/'))
return tmp_path
def _publish_agent(version='1.16.0.0'):
"""Stage a build so there is something to upgrade to."""
agentstore.store_version('agent', version, _PE_BYTES, make_current=True)
def _endpoint(db, hostname, agentversion='1.15.0.0'):
from shopdb.api import Asset, AssetType
from plugins.computers.models import Computer
from plugins.endpoints.models import EndpointState
assettype = AssetType.query.filter_by(assettype='computer').first()
if assettype is None:
assettype = AssetType(assettype='computer', pluginname='computers',
tablename='computers')
db.session.add(assettype)
db.session.flush()
asset = Asset(assetnumber=hostname, name=hostname,
assettypeid=assettype.assettypeid, isactive=True)
db.session.add(asset)
db.session.flush()
db.session.add(Computer(assetid=asset.assetid, hostname=hostname))
db.session.add(EndpointState(assetid=asset.assetid, hostname=hostname,
agentversion=agentversion))
db.session.commit()
return asset.assetid
def _login(db, client, username, permissions=None, admin=False):
"""A user holding exactly the named permissions (or the admin role)."""
from shopdb.core.models import User, Role, Permission
Permission.seed()
db.session.flush()
rolename = 'admin' if admin else username + 'role'
role = Role.query.filter_by(rolename=rolename).first()
if role is None:
role = Role(rolename=rolename)
db.session.add(role)
db.session.flush()
if not admin:
for name in (permissions or []):
perm = Permission.query.filter_by(name=name).first()
if perm is not None and perm not in role.permissions:
role.permissions.append(perm)
db.session.commit()
user = User(username=username, email=username + '@test.local',
passwordhash=generate_password_hash('testpass'))
user.roles.append(role)
db.session.add(user)
db.session.commit()
response = client.post('/api/auth/login',
json={'username': username, 'password': 'testpass'})
token = response.get_json()['data']['access_token']
return {'Authorization': f'Bearer {token}'}
def test_an_upgrade_queues_as_a_pinned_run_script(client, db, app):
"""The push must ride the normal script channel, digest-pinned.
Landing as run_script is what makes the body verifiable on the endpoint;
a bespoke action would need its own pinning and would not get it.
"""
from plugins.endpoints.models import EndpointCommand
_publish_agent()
assetid = _endpoint(db, 'UPGRADE01')
headers = _login(db, client, 'upgradeadmin', admin=True)
response = client.post(UPGRADE_URL.format(assetid), json={}, headers=headers)
assert response.status_code == 201
command = EndpointCommand.query.filter_by(assetid=assetid).first()
assert command is not None
assert command.action == 'run_script'
seeded = agentupgradescript.seed_upgrade_script()
assert command.target == 'script:{}'.format(seeded['scriptid'])
# Pinned to the exact body approved at enqueue, so a later edit cannot
# swap what the endpoint runs.
assert command.pinnedsha256 == seeded['sha256']
def test_the_upgrade_requires_the_agentupdate_permission(client, db, app):
"""endpoints.command alone must not be able to swap the SYSTEM agent."""
_publish_agent()
assetid = _endpoint(db, 'UPGRADE02')
headers = _login(db, client, 'plainoperator',
permissions=['endpoints.view', 'endpoints.command'])
response = client.post(UPGRADE_URL.format(assetid), json={},
headers=headers)
assert response.status_code == 403
def test_an_upgrade_is_refused_when_no_build_is_published(client, db, app):
"""Nothing staged means there is no payload; refuse before queueing.
Queueing anyway would send an endpoint to fetch a package that does not
exist and fail opaquely minutes later.
"""
from plugins.endpoints.models import EndpointCommand
assetid = _endpoint(db, 'UPGRADE03')
headers = _login(db, client, 'nobuildadmin', admin=True)
response = client.post(UPGRADE_URL.format(assetid), json={},
headers=headers)
assert response.status_code == 400
assert 'No agent build is published' in \
response.get_json()['data']['error']['message']
assert EndpointCommand.query.filter_by(assetid=assetid).count() == 0
def test_an_endpoint_lock_still_blocks_the_upgrade(client, db, app):
"""The delegate's lock check must not be bypassed by this route.
A quarantined machine is reserved by a person; a fleet agent swap is
exactly the kind of action that must respect that.
"""
from plugins.endpoints.models import EndpointCommand
_publish_agent()
assetid = _endpoint(db, 'UPGRADE04')
# Lock as one user, then act as a different non-admin holder of the grant.
owner = _login(db, client, 'lockowner', admin=True)
locked = client.post(f'/api/endpoints/{assetid}/lock',
json={'reason': 'bench repair'}, headers=owner)
assert locked.status_code in (200, 201)
headers = _login(db, client, 'upgradeoperator',
permissions=['endpoints.view', 'endpoints.command',
'endpoints.agentupdate',
'endpoints.runscript'])
response = client.post(UPGRADE_URL.format(assetid), json={},
headers=headers)
assert response.status_code == 403
assert 'locked' in \
response.get_json()['data']['error']['message'].lower()
assert EndpointCommand.query.filter_by(assetid=assetid).count() == 0
def test_refused_callers_never_trigger_repackaging(client, db, app,
monkeypatch):
"""Lock and runscript checks run BEFORE the package and script are rebuilt.
Repackaging rewrites a shared file and script row; a caller who will be
refused anyway must not be able to cause that side effect.
"""
calls = []
monkeypatch.setattr(agentregistry, 'register_current_agent_package',
lambda: calls.append('package'))
monkeypatch.setattr(agentupgradescript, 'seed_upgrade_script',
lambda: calls.append('script'))
_publish_agent()
assetid = _endpoint(db, 'UPGRADE09')
noscript = _login(db, client, 'noscriptoperator',
permissions=['endpoints.view', 'endpoints.command',
'endpoints.agentupdate'])
response = client.post(UPGRADE_URL.format(assetid), json={},
headers=noscript)
assert response.status_code == 403
assert 'endpoints.runscript' in \
response.get_json()['data']['error']['message']
owner = _login(db, client, 'lockowner2', admin=True)
client.post(f'/api/endpoints/{assetid}/lock',
json={'reason': 'bench repair'}, headers=owner)
locked = _login(db, client, 'lockedoperator',
permissions=['endpoints.view', 'endpoints.command',
'endpoints.agentupdate',
'endpoints.runscript'])
response = client.post(UPGRADE_URL.format(assetid), json={},
headers=locked)
assert response.status_code == 403
assert calls == []
def test_package_write_leaves_no_temp_files(client, db, app, isolate_store):
_publish_agent()
agentregistry.register_current_agent_package()
agentregistry.register_current_agent_package()
leftovers = [name for _root, _dirs, files in
os.walk(isolate_store / 'applicationpackages')
for name in files if name.endswith('.tmp')]
assert leftovers == []
def test_a_second_upgrade_cannot_stack_on_an_in_flight_command(client, db, app):
"""at-most-one-in-flight must still hold through this route."""
from plugins.endpoints.models import EndpointCommand
_publish_agent()
assetid = _endpoint(db, 'UPGRADE05')
headers = _login(db, client, 'stackadmin', admin=True)
first = client.post(UPGRADE_URL.format(assetid), json={}, headers=headers)
assert first.status_code == 201
second = client.post(UPGRADE_URL.format(assetid), json={}, headers=headers)
assert second.status_code == 409
assert EndpointCommand.query.filter_by(assetid=assetid).count() == 1
def test_the_upgrade_refreshes_the_package_before_queueing(client, db, app):
"""A newer staged build must be what gets pushed.
If the route queued against a previously registered row, an operator who
uploaded a new agent and immediately pushed would ship the old one.
"""
from shopdb.api import Application
_publish_agent('1.16.0.0')
assetid = _endpoint(db, 'UPGRADE06')
headers = _login(db, client, 'refreshadmin', admin=True)
client.post(UPGRADE_URL.format(assetid), json={}, headers=headers)
row = Application.query.filter_by(
appname=agentregistry.AGENT_APPNAME).first()
firstsha = row.installersha256
# Stage a different build and push to a second endpoint.
agentstore.store_version('agent', '1.17.0.0', b'MZ' + b'\x01' * 4096,
make_current=True)
second = _endpoint(db, 'UPGRADE07')
response = client.post(UPGRADE_URL.format(second), json={}, headers=headers)
assert response.status_code == 201
db.session.expire_all()
row = Application.query.filter_by(
appname=agentregistry.AGENT_APPNAME).first()
assert row.installersha256 != firstsha
def test_a_failed_push_is_not_audited_as_an_upgrade(client, db, app):
"""A refused push must not leave an AgentUpgrade event behind."""
from shopdb.api import AuditLog
_publish_agent()
assetid = _endpoint(db, 'UPGRADE08')
headers = _login(db, client, 'auditadmin', admin=True)
client.post(UPGRADE_URL.format(assetid), json={}, headers=headers)
before = AuditLog.query.filter_by(entitytype='AgentUpgrade').count()
# Second push is refused by the in-flight rule.
blocked = client.post(UPGRADE_URL.format(assetid), json={}, headers=headers)
assert blocked.status_code == 409
after = AuditLog.query.filter_by(entitytype='AgentUpgrade').count()
assert after == before
def test_the_seeded_script_body_is_written_and_pinned(client, db, app,
isolate_store):
"""The agent verifies the body by digest, so both must exist and agree."""
import hashlib
_publish_agent()
assetid = _endpoint(db, 'UPGRADE09')
headers = _login(db, client, 'bodyadmin', admin=True)
response = client.post(UPGRADE_URL.format(assetid), json={},
headers=headers)
assert response.status_code == 201
seeded = agentupgradescript.seed_upgrade_script()
path = os.path.join(str(isolate_store / 'scripts'),
'script-{}.ps1'.format(seeded['scriptid']))
assert os.path.isfile(path)
with open(path, 'rb') as handle:
body = handle.read()
assert hashlib.sha256(body).hexdigest() == seeded['sha256']
# The delivery path must be fully resolved, not a literal placeholder.
assert b'{{AGENT_APPID}}' not in body

View File

@@ -0,0 +1,158 @@
"""The agent version guard on command enqueue.
An agent older than an action has no case for it and falls through to its
unknown-action default, answering "Unknown or disallowed action". That reads as
an authorization refusal, so the operator goes looking at permissions they
already hold - which is exactly what happened on a live endpoint running 1.15.0
after collect_log shipped in 1.16.0.
These tests pin the refusal at the SERVER, before the command is ever queued,
and pin that the message names both versions so the answer is actionable.
"""
from werkzeug.security import generate_password_hash
def _endpoint(db, hostname, agentversion):
"""An Asset + Computer pair whose reported state carries an agent version."""
from shopdb.api import Asset, AssetType
from plugins.computers.models import Computer
from plugins.endpoints.models import EndpointState
assettype = AssetType.query.filter_by(assettype='computer').first()
if assettype is None:
assettype = AssetType(assettype='computer', pluginname='computers',
tablename='computers')
db.session.add(assettype)
db.session.flush()
asset = Asset(assetnumber=hostname, name=hostname,
assettypeid=assettype.assettypeid, isactive=True)
db.session.add(asset)
db.session.flush()
db.session.add(Computer(assetid=asset.assetid, hostname=hostname))
db.session.add(EndpointState(assetid=asset.assetid, hostname=hostname,
agentversion=agentversion))
db.session.commit()
return asset.assetid
def _admin(db, client, username='guardadmin'):
"""An admin, so permission and policy gates cannot be what refuses."""
from shopdb.core.models import User, Role, Permission
Permission.seed()
db.session.flush()
role = Role.query.filter_by(rolename='admin').first()
if role is None:
role = Role(rolename='admin')
db.session.add(role)
db.session.commit()
user = User(username=username, email=username + '@test.local',
passwordhash=generate_password_hash('testpass'))
user.roles.append(role)
db.session.add(user)
db.session.commit()
response = client.post('/api/auth/login',
json={'username': username, 'password': 'testpass'})
token = response.get_json()['data']['access_token']
return {'Authorization': f'Bearer {token}'}
def _collect(client, headers, assetid):
return client.post(f'/api/endpoints/{assetid}/commands',
json={'action': 'collect_log',
'target': r'C:\logs\app.log'},
headers=headers)
def test_an_agent_below_the_minimum_is_refused_at_enqueue(app, client, db):
"""The command must not be queued at all.
Queueing it would spend the endpoint's one in-flight command slot and leave
a failed row that reads as endpoint misbehaviour, when the truth is the
agent simply predates the feature.
"""
from plugins.endpoints.models import EndpointCommand
assetid = _endpoint(db, 'oldagentbox', '1.15.0.0')
headers = _admin(db, client)
response = _collect(client, headers, assetid)
assert response.status_code == 400
message = response.get_json()['data']['error']['message']
# Both versions, or the operator cannot tell what to do about it.
assert '1.16.0' in message
assert '1.15.0.0' in message
assert EndpointCommand.query.filter_by(assetid=assetid).count() == 0
def test_an_agent_at_the_minimum_is_allowed(app, client, db):
"""The guard must not refuse a capable endpoint.
1.16.0.0 against a 1.16.0 floor is the four-vs-three-part spelling of the
same build; a lexical compare would get this wrong.
"""
from plugins.endpoints.models import EndpointCommand
assetid = _endpoint(db, 'newagentbox', '1.16.0.0')
headers = _admin(db, client)
response = _collect(client, headers, assetid)
assert response.status_code in (200, 201)
assert EndpointCommand.query.filter_by(assetid=assetid,
action='collect_log').count() == 1
def test_a_lexically_larger_but_older_version_is_still_refused(app, client, db):
"""'1.9.0.0' must not pass a '1.16.0' floor.
String comparison says '9' > '1' and lets it through. This is the specific
bug the numeric parse exists to prevent, so it gets its own test rather
than living only in the helper's unit coverage.
"""
assetid = _endpoint(db, 'lexicaltrapbox', '1.9.0.0')
headers = _admin(db, client)
response = _collect(client, headers, assetid)
assert response.status_code == 400
assert '1.16.0' in response.get_json()['data']['error']['message']
def test_an_unknown_agent_version_is_refused_rather_than_guessed(app, client, db):
"""A placeholder version fails closed.
Seeded rows carry '0.0.0-sample'. There is no honest ordering between a
placeholder and a real build, and letting it through only moves the failure
to the agent where the error message is the misleading one.
"""
assetid = _endpoint(db, 'samplebox', '0.0.0-sample')
headers = _admin(db, client)
response = _collect(client, headers, assetid)
assert response.status_code == 400
def test_an_action_without_a_floor_is_unaffected(app, client, db):
"""The guard is per-action, not global.
collect_now declares no minimum, so an old agent must still be able to run
it - otherwise adding a floor to one action quietly narrows the others.
"""
from plugins.endpoints.models import EndpointCommand
assetid = _endpoint(db, 'refreshbox', '1.11.0.0')
headers = _admin(db, client)
response = client.post(f'/api/endpoints/{assetid}/commands',
json={'action': 'collect_now'}, headers=headers)
assert response.status_code in (200, 201)
assert EndpointCommand.query.filter_by(assetid=assetid,
action='collect_now').count() == 1

View File

@@ -0,0 +1,90 @@
"""Agent binary upload version gating: PE-fallback and precedence.
The upload route no longer rejects the version at raw input. A typed value is
only a hint; the dotted-numeric FILE version in the binary's PE resource is the
authority. The route falls back to it when the typed value is missing or not
dotted-numeric, and only refuses when neither yields a usable version.
Building a real PE with a RT_VERSION resource inline is impractical (see the
agentstore unit tests), so these API tests monkeypatch read_pe_file_version to
stand in for the parser. PE-magic bytes keep looks_like_pe happy so the request
reaches the version gate.
"""
import io
from plugins.endpoints.api import agentstore
from plugins.endpoints.api import agentupload
from shopdb.config import Config
UPLOAD_URL = '/api/endpoints/siteagent/binaries/{}'
_PE_BYTES = b'MZ' + b'\x00' * 128
def _upload(client, headers, monkeypatch, detected, typed=None):
monkeypatch.setattr(agentstore, 'read_pe_file_version',
lambda data: detected)
form = {'file': (io.BytesIO(_PE_BYTES), 'shopdbagent.exe')}
if typed is not None:
form['version'] = typed
return client.post(
UPLOAD_URL.format('agent'),
data=form,
content_type='multipart/form-data',
headers=headers)
def test_upload_falls_back_to_pe_version_when_typed_is_missing(
client, db, app, auth_headers, monkeypatch):
response = _upload(client, auth_headers, monkeypatch, '1.5.0.0')
assert response.status_code == 200, response.get_json()
payload = response.get_json()['data']
assert payload['current'] == '1.5.0.0'
assert any(v['version'] == '1.5.0.0' for v in payload['versions'])
def test_upload_falls_back_when_typed_is_not_dotted_numeric(
client, db, app, auth_headers, monkeypatch):
response = _upload(client, auth_headers, monkeypatch, '1.5.0.0',
typed='1.5.0+1a1bf0640f956634dada1b34840')
assert response.status_code == 200, response.get_json()
assert response.get_json()['data']['current'] == '1.5.0.0'
def test_upload_prefers_valid_typed_version_over_pe(
client, db, app, auth_headers, monkeypatch):
response = _upload(client, auth_headers, monkeypatch, '9.9.9.9',
typed='1.6.3')
assert response.status_code == 200, response.get_json()
assert response.get_json()['data']['current'] == '1.6.3'
def test_upload_rejects_when_neither_typed_nor_pe_is_usable(
client, db, app, auth_headers, monkeypatch):
response = _upload(client, auth_headers, monkeypatch, None)
assert response.status_code == 400
assert 'dotted-numeric' in response.get_json()['data']['error']['message']
def test_upload_cap_admits_a_self_contained_single_file_publish():
"""The tray publishes ~69 MB; the cap must clear a real binary.
This is the bug that presented as "upload does not detect version": the
size check returns before the PE is parsed, so an oversized file looks
like a parser failure to the operator. Pinned at 100 MB rather than the
observed 69 MB so runtime growth does not silently re-break it.
"""
assert agentupload._MAX_UPLOAD_BYTES >= 100 * 1024 * 1024
def test_request_cap_stays_above_the_agent_upload_cap():
"""MAX_CONTENT_LENGTH bounds the whole multipart envelope, not the file.
Setting the two equal rejects a file sitting exactly at the agent cap,
because the boundaries and form fields push the request over. They must
move together, with real headroom between them.
"""
envelope = Config.MAX_CONTENT_LENGTH
assert envelope > agentupload._MAX_UPLOAD_BYTES
assert envelope - agentupload._MAX_UPLOAD_BYTES >= 1024 * 1024

View File

@@ -0,0 +1,165 @@
"""A concurrent application insert must not sink the whole check-in.
The unique index on (assetid, appfingerprint) detects a second writer inserting
the same application row; it does not resolve one. Before the savepoint went in,
the IntegrityError from the insert flush escaped generic_collect, reached the
global handler, rolled back the WHOLE check-in - state, events and hardware -
and answered the agent with operator-facing 409 form copy.
The insert now behaves like the delete-count mismatch beside it: warn, keep the
telemetry, and let the next check-in (which reports the full list again) settle
the inventory.
"""
import hashlib
from sqlalchemy.exc import IntegrityError
from shopdb.core.models import Asset, AssetType
from plugins.endpoints.models import (
EndpointApplication, EndpointEvent, EndpointState,
)
from plugins.endpoints.plugin import EndpointsPlugin
def _fingerprint(name, version, publisher):
data = '{}|{}|{}'.format(name or '', version or '', publisher or '')
return hashlib.sha256(data.encode('utf-8')).hexdigest()
def _computer(db, hostname):
from plugins.computers.models import Computer
assettype = AssetType.query.filter_by(assettype='computer').first()
if assettype is None:
assettype = AssetType(assettype='computer')
db.session.add(assettype)
db.session.flush()
asset = Asset(assetnumber=hostname, name=hostname,
assettypeid=assettype.assettypeid, isactive=True)
db.session.add(asset)
db.session.flush()
db.session.add(Computer(assetid=asset.assetid, hostname=hostname))
db.session.commit()
return asset.assetid
def _payload(hostname, applications):
return {
'hostname': hostname,
'pendingreboot': True,
'applications': applications,
'events': [{
'eventid': 4625,
'eventrecordid': 501,
'eventchannel': 'Security',
'eventsource': 'Microsoft-Windows-Security-Auditing',
'eventmessage': 'Failed logon',
}],
}
def test_insert_conflict_keeps_the_checkin(db, monkeypatch):
"""A REAL IntegrityError on the insert flush leaves the check-in intact.
The first (bulk) insert adds a genuine duplicate row, so the flush raises on
the unique index exactly as a concurrent check-in would make it. The
reconcile then re-reads and re-inserts, and state plus events still commit.
"""
assetid = _computer(db, 'APPRACE01')
plugin = EndpointsPlugin()
real_insert = EndpointsPlugin._insert_applications
calls = []
def conflicting_insert(assetid_arg, incoming, fingerprints):
rows = real_insert(assetid_arg, incoming, fingerprints)
calls.append(list(fingerprints))
if len(calls) == 1:
# The conflicting row a second writer would have committed.
first = rows[0]
db.session.add(EndpointApplication(
assetid=first.assetid,
displayname=first.displayname,
displayversion=first.displayversion,
publisher=first.publisher,
appfingerprint=first.appfingerprint,
))
return rows
monkeypatch.setattr(EndpointsPlugin, '_insert_applications',
staticmethod(conflicting_insert))
applications = [
{'displayname': 'Alpha', 'displayversion': '1.0', 'publisher': 'ACME',
'fingerprint': _fingerprint('Alpha', '1.0', 'ACME')},
{'displayname': 'Beta', 'displayversion': '2.0', 'publisher': 'ACME',
'fingerprint': _fingerprint('Beta', '2.0', 'ACME')},
]
result = plugin.apply_collector_payload(_payload('APPRACE01', applications))
db.session.commit()
# The collision was detected and reported rather than raised.
assert len(calls) > 1, 'the bulk insert should have collided and reconciled'
assert any('application insert conflict' in w for w in result['warnings'])
# The telemetry the check-in carried is still there.
state = EndpointState.query.filter_by(assetid=assetid).first()
assert state is not None
assert state.pendingreboot is True
assert state.lastreportedat is not None
assert EndpointEvent.query.filter_by(assetid=assetid).count() == 1
# And the inventory reconciled rather than being abandoned.
stored = {row.appfingerprint for row
in EndpointApplication.query.filter_by(assetid=assetid).all()}
assert stored == {app['fingerprint'] for app in applications}
def test_reconcile_skips_fingerprints_that_now_exist(db):
"""Reconcile inserts only what is missing and counts the rest as skipped."""
assetid = _computer(db, 'APPRACE02')
plugin = EndpointsPlugin()
taken = _fingerprint('Alpha', '1.0', 'ACME')
missing = _fingerprint('Beta', '2.0', 'ACME')
db.session.add(EndpointApplication(
assetid=assetid, displayname='Alpha', displayversion='1.0',
publisher='ACME', appfingerprint=taken))
db.session.commit()
incoming = {
taken: {'displayname': 'Alpha', 'displayversion': '1.0',
'publisher': 'ACME'},
missing: {'displayname': 'Beta', 'displayversion': '2.0',
'publisher': 'ACME'},
}
warnings = []
stored = plugin._reconcile_applications(assetid, incoming,
[taken, missing], warnings)
db.session.commit()
assert stored == 1
assert warnings == ['application insert conflict: stored 1, skipped 1 '
'of 2 new (concurrent update?)']
rows = EndpointApplication.query.filter_by(assetid=assetid).all()
assert len(rows) == 2
assert {r.appfingerprint for r in rows} == {taken, missing}
def test_the_unique_index_is_really_there(db):
"""The whole fix rests on the constraint raising. Prove it does."""
assetid = _computer(db, 'APPRACE03')
fingerprint = _fingerprint('Alpha', '1.0', 'ACME')
for _ in range(2):
db.session.add(EndpointApplication(
assetid=assetid, displayname='Alpha', displayversion='1.0',
publisher='ACME', appfingerprint=fingerprint))
raised = False
try:
db.session.flush()
except IntegrityError:
raised = True
db.session.rollback()
assert raised, 'endpointapplications has no unique (assetid, appfingerprint)'

View File

@@ -0,0 +1,377 @@
"""Tests for application inventory delta-based updates.
Tests the _replace_applications() method with fingerprint-based delta logic,
including safety features: fingerprint validation, delete count verification,
memory limits, and concurrent update protection.
"""
import hashlib
import pytest
from plugins.endpoints.models import EndpointApplication
from plugins.endpoints.plugin import EndpointsPlugin
def compute_test_fingerprint(name, version, publisher):
"""Compute fingerprint matching agent algorithm."""
name = name or ''
version = version or ''
publisher = publisher or ''
data = f"{name}|{version}|{publisher}"
return hashlib.sha256(data.encode('utf-8')).hexdigest()
def _make_asset(db, assetnumber):
"""A bare computer-type Asset; _replace_applications needs only the id."""
from shopdb.core.models import Asset, AssetType
assettype = AssetType.query.filter_by(assettype='computer').first()
if assettype is None:
assettype = AssetType(assettype='computer')
db.session.add(assettype)
db.session.flush()
asset = Asset(assetnumber=assetnumber, name=assetnumber,
assettypeid=assettype.assettypeid, isactive=True)
db.session.add(asset)
db.session.commit()
return asset.assetid
class TestApplicationDelta:
"""Test delta-based application inventory updates."""
@pytest.fixture
def plugin(self):
return EndpointsPlugin()
@pytest.fixture
def asset_id(self, db):
"""Create a test asset."""
return _make_asset(db, 'TEST-001')
def test_fingerprint_validation_valid(self, plugin):
"""Valid fingerprints (64 lowercase hex chars) pass validation."""
valid_fps = [
'a' * 64, # All lowercase a
'0' * 64, # All zeros
'abc123' * 10 + 'abcd', # Mix of hex chars (64 total)
]
for fp in valid_fps:
assert plugin._is_valid_fingerprint(fp), f"Should accept: {fp}"
def test_fingerprint_validation_invalid(self, plugin):
"""Invalid fingerprints are rejected."""
invalid_fps = [
'', # Empty
'abc', # Too short
'A' * 64, # Uppercase
'g' * 64, # Non-hex character
'abc-def' * 9, # Contains hyphens
'0' * 63, # One char too short
'0' * 65, # One char too long
]
for fp in invalid_fps:
assert not plugin._is_valid_fingerprint(fp), f"Should reject: {fp}"
def test_delta_no_changes(self, plugin, asset_id, db):
"""No apps added/removed when inventory unchanged."""
# Initial insert: 3 apps
apps_v1 = [
{
'displayname': 'Chrome',
'displayversion': '117.0',
'publisher': 'Google',
'fingerprint': compute_test_fingerprint('Chrome', '117.0', 'Google')
},
{
'displayname': 'Firefox',
'displayversion': '119.0',
'publisher': 'Mozilla',
'fingerprint': compute_test_fingerprint('Firefox', '119.0', 'Mozilla')
},
{
'displayname': 'Office',
'displayversion': '16.0',
'publisher': 'Microsoft',
'fingerprint': compute_test_fingerprint('Office', '16.0', 'Microsoft')
},
]
warnings = []
result = plugin._replace_applications(asset_id, apps_v1, warnings)
db.session.commit()
assert result == 3
assert len(warnings) == 0
assert EndpointApplication.query.filter_by(assetid=asset_id).count() == 3
# Second report: same apps (no changes)
warnings = []
result = plugin._replace_applications(asset_id, apps_v1, warnings)
db.session.commit()
assert result == 0 # 0 added
assert len(warnings) == 0
assert EndpointApplication.query.filter_by(assetid=asset_id).count() == 3 # Still 3
def test_delta_add_remove(self, plugin, asset_id, db):
"""Detect adds and removes correctly."""
# Initial: 2 apps
apps_v1 = [
{
'displayname': 'Chrome',
'displayversion': '117.0',
'publisher': 'Google',
'fingerprint': compute_test_fingerprint('Chrome', '117.0', 'Google')
},
{
'displayname': 'Firefox',
'displayversion': '119.0',
'publisher': 'Mozilla',
'fingerprint': compute_test_fingerprint('Firefox', '119.0', 'Mozilla')
},
]
warnings = []
plugin._replace_applications(asset_id, apps_v1, warnings)
db.session.commit()
assert EndpointApplication.query.filter_by(assetid=asset_id).count() == 2
# Update: Remove Firefox, Add Office
apps_v2 = [
{
'displayname': 'Chrome',
'displayversion': '117.0',
'publisher': 'Google',
'fingerprint': compute_test_fingerprint('Chrome', '117.0', 'Google')
},
{
'displayname': 'Office',
'displayversion': '16.0',
'publisher': 'Microsoft',
'fingerprint': compute_test_fingerprint('Office', '16.0', 'Microsoft')
},
]
warnings = []
result = plugin._replace_applications(asset_id, apps_v2, warnings)
db.session.commit()
assert result == 1 # 1 added (Office)
assert len(warnings) == 0
apps = EndpointApplication.query.filter_by(assetid=asset_id).all()
assert len(apps) == 2
app_names = {app.displayname for app in apps}
assert app_names == {'Chrome', 'Office'}
def test_delta_version_change(self, plugin, asset_id, db):
"""Version change detected (different fingerprint)."""
# Initial: Chrome 117.0
apps_v1 = [
{
'displayname': 'Chrome',
'displayversion': '117.0',
'publisher': 'Google',
'fingerprint': compute_test_fingerprint('Chrome', '117.0', 'Google')
},
]
warnings = []
plugin._replace_applications(asset_id, apps_v1, warnings)
db.session.commit()
# Update: Chrome 118.0 (different fingerprint)
apps_v2 = [
{
'displayname': 'Chrome',
'displayversion': '118.0',
'publisher': 'Google',
'fingerprint': compute_test_fingerprint('Chrome', '118.0', 'Google')
},
]
warnings = []
result = plugin._replace_applications(asset_id, apps_v2, warnings)
db.session.commit()
assert result == 1 # 1 added (new version)
apps = EndpointApplication.query.filter_by(assetid=asset_id).all()
assert len(apps) == 1
assert apps[0].displayversion == '118.0'
def test_invalid_fingerprint_skipped(self, plugin, asset_id, db):
"""Apps with invalid fingerprints are skipped with warning."""
apps = [
{
'displayname': 'Chrome',
'displayversion': '117.0',
'publisher': 'Google',
'fingerprint': 'INVALID' # Too short
},
{
'displayname': 'Firefox',
'displayversion': '119.0',
'publisher': 'Mozilla',
'fingerprint': compute_test_fingerprint('Firefox', '119.0', 'Mozilla')
},
]
warnings = []
result = plugin._replace_applications(asset_id, apps, warnings)
db.session.commit()
assert result == 1 # Only Firefox added
assert len(warnings) == 1
assert 'invalid fingerprint' in warnings[0].lower()
assert 'Chrome' in warnings[0]
apps = EndpointApplication.query.filter_by(assetid=asset_id).all()
assert len(apps) == 1
assert apps[0].displayname == 'Firefox'
def test_legacy_fallback_no_fingerprints(self, plugin, asset_id, db):
"""Legacy mode works when agent doesn't send fingerprints."""
apps = [
{
'displayname': 'Chrome',
'displayversion': '117.0',
'publisher': 'Google',
# No fingerprint key
},
{
'displayname': 'Firefox',
'displayversion': '119.0',
'publisher': 'Mozilla',
# No fingerprint key
},
]
warnings = []
result = plugin._replace_applications(asset_id, apps, warnings)
db.session.commit()
assert result == 2
assert len(warnings) == 0
apps = EndpointApplication.query.filter_by(assetid=asset_id).all()
assert len(apps) == 2
assert all(app.appfingerprint is None for app in apps)
def test_empty_inventory(self, plugin, asset_id, db):
"""Empty inventory removes all apps."""
# Initial: 2 apps
apps_v1 = [
{
'displayname': 'Chrome',
'displayversion': '117.0',
'publisher': 'Google',
'fingerprint': compute_test_fingerprint('Chrome', '117.0', 'Google')
},
{
'displayname': 'Firefox',
'displayversion': '119.0',
'publisher': 'Mozilla',
'fingerprint': compute_test_fingerprint('Firefox', '119.0', 'Mozilla')
},
]
warnings = []
plugin._replace_applications(asset_id, apps_v1, warnings)
db.session.commit()
assert EndpointApplication.query.filter_by(assetid=asset_id).count() == 2
# Update: Empty list
warnings = []
result = plugin._replace_applications(asset_id, [], warnings)
db.session.commit()
assert result == 0
assert EndpointApplication.query.filter_by(assetid=asset_id).count() == 0
def test_missing_displayname_skipped(self, plugin, asset_id, db):
"""Apps without displayname are skipped."""
apps = [
{
# No displayname
'displayversion': '117.0',
'publisher': 'Google',
'fingerprint': compute_test_fingerprint('', '117.0', 'Google')
},
{
'displayname': 'Firefox',
'displayversion': '119.0',
'publisher': 'Mozilla',
'fingerprint': compute_test_fingerprint('Firefox', '119.0', 'Mozilla')
},
]
warnings = []
result = plugin._replace_applications(asset_id, apps, warnings)
db.session.commit()
assert result == 1 # Only Firefox added
assert len(warnings) == 1
assert 'no displayname' in warnings[0].lower()
def test_string_truncation(self, plugin, asset_id, db):
"""Long strings are truncated to column limits."""
apps = [
{
'displayname': 'A' * 300, # Exceeds 255 char limit
'displayversion': 'B' * 200, # Exceeds 128 char limit
'publisher': 'C' * 300, # Exceeds 255 char limit
'fingerprint': compute_test_fingerprint('A' * 300, 'B' * 200, 'C' * 300)
},
]
warnings = []
result = plugin._replace_applications(asset_id, apps, warnings)
db.session.commit()
assert result == 1
app = EndpointApplication.query.filter_by(assetid=asset_id).first()
assert len(app.displayname) == 255
assert len(app.displayversion) == 128
assert len(app.publisher) == 255
class TestApplicationDeltaSafety:
"""Test safety features: locking, delete verification, memory limits."""
@pytest.fixture
def plugin(self):
return EndpointsPlugin()
@pytest.fixture
def asset_id(self, db):
"""Create a test asset."""
return _make_asset(db, 'TEST-SAFETY-001')
def test_compute_fingerprint_consistency(self):
"""Fingerprint algorithm is deterministic."""
fp1 = compute_test_fingerprint('Chrome', '117.0', 'Google')
fp2 = compute_test_fingerprint('Chrome', '117.0', 'Google')
assert fp1 == fp2
# Different inputs produce different fingerprints
fp3 = compute_test_fingerprint('Chrome', '118.0', 'Google')
assert fp1 != fp3
def test_fingerprint_null_handling(self):
"""Null values handled correctly (converted to empty string)."""
fp1 = compute_test_fingerprint('App', None, None)
fp2 = compute_test_fingerprint('App', '', '')
assert fp1 == fp2 # None and '' should produce same fingerprint
def test_fingerprint_format(self):
"""Fingerprints are 64 lowercase hex chars."""
fp = compute_test_fingerprint('Test', '1.0', 'Vendor')
assert len(fp) == 64
assert fp.islower()
assert all(c in '0123456789abcdef' for c in fp)

View File

@@ -0,0 +1,677 @@
"""One-line bootstrap installer: token lifecycle and the two public downloads.
The token is the whole authorization on a PC that has no agent, so these tests
read the real routes rather than the helpers: that an admin mints and revokes,
that anything not currently usable answers 404 rather than confirming itself,
that only the package download spends a use, that the package can never carry a
collector key, and that every fetch leaves an audit row with the caller's IP.
"""
import datetime
import hashlib
import io
import json
import logging
import zipfile
import pytest
from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.x509.oid import NameOID
from plugins.endpoints.api import agentstore, siteagent
from plugins.endpoints.models import EndpointBootstrapToken
from shopdb.core.models import AuditLog, Setting
TOKENS_URL = '/api/endpoints/siteagent/bootstrap/tokens'
SCRIPT_URL = '/api/endpoints/siteagent/bootstrap.ps1'
PACKAGE_URL = '/api/endpoints/siteagent/bootstrap/package'
BASE_URL = 'https://shopdb.test/shopdb'
TOKEN_HEADER = 'X-Endpoint-Bootstrap-Token'
QUERY_SETTING = 'plugin.endpoints.bootstrapquerytoken'
# Admin-only staging, NOT C:\Windows\Temp: a standard user can pre-plant a
# path there and have it run elevated.
STAGE_DIR = r'C:\Program Files\ShopDB\bootstrap'
STAGED_SCRIPT = STAGE_DIR + r'\shopdb-bootstrap.ps1'
ADMIN_SID = 'S-1-5-32-544'
SYSTEM_SID = 'S-1-5-18'
# The marker the admin "Download .ps1" link carries, so the server can tell an
# admin's browser click apart from a bay still on a pre-change pasted command.
UI_LINK_QUERY = 'via=download'
def _expected_command(secret, base=BASE_URL):
"""Stage admin-only, download with the token in a header, check the owner,
run, delete. Never `irm | iex` - Defender kills that shape."""
return (
'powershell -ExecutionPolicy Bypass -c "'
"try {{ New-Item -ItemType Directory -Force -Path '{dir}' "
'| Out-Null; '
"icacls '{dir}' /inheritance:r /grant '*{admin}:(OI)(CI)F' "
"'*{system}:(OI)(CI)F' | Out-Null; "
'if ((Get-Variable LASTEXITCODE -ValueOnly) -ne 0) '
"{{ Write-Host 'Refusing to run: could not restrict "
"{dir} to administrators (icacls failed). Fix the directory "
"or delete it, then run the command again.' -ForegroundColor Red; "
'exit 6 }}; '
"Remove-Item '{path}' -Force -ErrorAction SilentlyContinue; "
"Invoke-WebRequest -UseBasicParsing "
"'{base}/api/endpoints/siteagent/bootstrap.ps1' "
"-Headers @{{'{header}'='{secret}'}} -OutFile '{path}'; "
"if ((Get-Acl '{path}').GetOwner("
'[Security.Principal.SecurityIdentifier]).Value -notin '
"@('{admin}','{system}',"
'[Security.Principal.WindowsIdentity]::GetCurrent().User.Value)) '
"{{ Write-Host 'Refusing to run: the staged bootstrap script is not "
"owned by an administrator.' -ForegroundColor Red; exit 5 }}; "
"& '{path}' }} finally {{ Remove-Item '{path}' -Force "
'-ErrorAction SilentlyContinue }}"'
).format(dir=STAGE_DIR, path=STAGED_SCRIPT, base=base, header=TOKEN_HEADER,
secret=secret, admin=ADMIN_SID, system=SYSTEM_SID)
def _token_header(secret):
return {TOKEN_HEADER: secret}
def _make_cert():
key = ec.generate_private_key(ec.SECP256R1())
name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, 'server.test')])
now = datetime.datetime.now(datetime.timezone.utc)
certificate = (x509.CertificateBuilder()
.subject_name(name).issuer_name(name)
.public_key(key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(now - datetime.timedelta(days=1))
.not_valid_after(now + datetime.timedelta(days=30))
.sign(key, hashes.SHA256()))
pem = certificate.public_bytes(serialization.Encoding.PEM)
spki = certificate.public_key().public_bytes(
serialization.Encoding.DER,
serialization.PublicFormat.SubjectPublicKeyInfo)
return pem, hashlib.sha256(spki).hexdigest()
@pytest.fixture(autouse=True)
def isolate(tmp_path, monkeypatch, app, db):
"""Empty agent store, no auto-detected certificate, a known base URL."""
store = tmp_path / 'siteagent'
store.mkdir()
monkeypatch.setattr(agentstore, '_siteagent_dir', lambda: str(store))
monkeypatch.setattr(siteagent, '_CERT_CANDIDATES', ())
Setting.set('site_base_url', BASE_URL, valuetype='string')
# The limiter is process-wide cache state; a noisy neighbour test would
# otherwise leak its budget into this module.
app.config['ENDPOINTS_BOOTSTRAP_RATELIMIT_ENABLED'] = False
yield tmp_path
app.config.pop('ENDPOINTS_BOOTSTRAP_RATELIMIT_ENABLED', None)
@pytest.fixture
def servercert(tmp_path, monkeypatch):
pem, pin = _make_cert()
path = tmp_path / 'server.crt'
path.write_bytes(pem)
monkeypatch.setattr(siteagent, '_CERT_CANDIDATES', (str(path),))
return pin
def _mint(client, auth_headers, **body):
return client.post(TOKENS_URL, json=body or {}, headers=auth_headers)
def _secret(client, auth_headers, **body):
response = _mint(client, auth_headers, **body)
assert response.status_code == 201, response.get_data(as_text=True)
return response.get_json()['data']
def _files(response):
assert response.status_code == 200, response.get_data(as_text=True)[:400]
archive = zipfile.ZipFile(io.BytesIO(response.data))
return {name: archive.read(name) for name in archive.namelist()}
# ---------------------------------------------------------------------------
# Admin API
# ---------------------------------------------------------------------------
def test_generate_returns_the_secret_and_the_ready_made_command(
client, db, auth_headers):
data = _secret(client, auth_headers, hours=6, maxuses=3)
secret = data['secret']
assert secret.startswith('sdbb_')
assert len(secret) == len('sdbb_') + 43
assert data['command'] == _expected_command(secret)
assert data['scripturl'] == (
'{0}/api/endpoints/siteagent/bootstrap.ps1?t={1}&{2}'.format(
BASE_URL, secret, UI_LINK_QUERY))
token = data['token']
assert token['tokenprefix'] == secret[:12]
assert token['maxuses'] == 3
assert token['usecount'] == 0
assert token['remaining'] == 3
assert token['state'] == 'active'
assert token['createdby'] == 'testadmin'
def test_the_command_downloads_and_runs_instead_of_piping_into_iex(
client, db, auth_headers):
"""Defender detects `irm ... | iex` as Trojan:Win32/Commando.A!ml and kills
the process before a line of it runs, so the published command downloads to
a file, runs it and removes it. It also stays one pasteable line, and holds
no `$`: pasted into a PowerShell prompt, the parent would expand those
before the child process ever saw them."""
data = _secret(client, auth_headers)
command = data['command']
assert ' | iex' not in command
assert 'iex' not in command.lower()
assert '$' not in command
assert '\n' not in command
assert data['secret'] in command
assert "Remove-Item '{0}' -Force".format(STAGED_SCRIPT) in command
assert 'finally' in command
# The link the UI offers instead is the route that really serves it.
assert client.get(data['scripturl'].split(BASE_URL, 1)[1]
).status_code == 200
def test_the_command_stages_where_only_administrators_can_write(
client, db, auth_headers):
"""C:\\Windows\\Temp lets any standard user create the exact path the
command runs elevated, so the staging directory moved under Program Files,
is created with an admin-only DACL, and the command refuses to run a staged
file owned by anyone but an administrator."""
command = _secret(client, auth_headers)['command']
assert r'C:\Windows\Temp' not in command
assert "-OutFile '{0}'".format(STAGED_SCRIPT) in command
assert "New-Item -ItemType Directory -Force -Path '{0}'".format(
STAGE_DIR) in command
assert "icacls '{0}' /inheritance:r".format(STAGE_DIR) in command
# Well-known SIDs, so a renamed or localized group cannot widen the grant.
assert "/grant '*{0}:(OI)(CI)F' '*{1}:(OI)(CI)F'".format(
ADMIN_SID, SYSTEM_SID) in command
assert "Get-Acl '{0}'".format(STAGED_SCRIPT) in command
assert 'GetOwner([Security.Principal.SecurityIdentifier]).Value' in command
assert "-notin @('{0}','{1}',".format(ADMIN_SID, SYSTEM_SID) in command
assert '[Security.Principal.WindowsIdentity]::GetCurrent().User.Value' \
in command
assert 'not owned by an administrator' in command
assert 'exit 5' in command
# A leftover from an earlier run is cleared before the download, so the
# owner test cannot pass on stale content.
assert command.index("Remove-Item '{0}'".format(STAGED_SCRIPT)) < \
command.index('Invoke-WebRequest')
def test_the_command_sends_the_token_in_a_header_not_the_query_string(
client, db, auth_headers):
"""A query string lands verbatim in the nginx and gunicorn access logs."""
data = _secret(client, auth_headers)
command = data['command']
assert '?t=' not in command
assert "-Headers @{{'{0}'='{1}'}}".format(TOKEN_HEADER,
data['secret']) in command
assert "'{0}/api/endpoints/siteagent/bootstrap.ps1'".format(
BASE_URL) in command
# The browser download link is the one place that cannot send a header,
# and it is marked so its own fetches are not counted as un-migrated bays.
assert data['scripturl'].endswith(
'?t={0}&{1}'.format(data['secret'], UI_LINK_QUERY))
def test_a_failed_acl_hardening_stops_the_command(client, db, auth_headers):
"""icacls is a native command: it raises nothing and $ErrorActionPreference
never applies to it, so an unchecked call leaves a staging directory that
still inherits the parent DACL and the line downloads into it anyway. The
exit code is read sigil-free, because the whole line must hold no `$`."""
command = _secret(client, auth_headers)['command']
assert '(Get-Variable LASTEXITCODE -ValueOnly) -ne 0' in command
assert 'LASTEXITCODE' in command and '$LASTEXITCODE' not in command
assert 'icacls failed' in command
assert 'exit 6' in command
# Checked before anything is fetched into the directory, not after.
assert command.index('LASTEXITCODE') > command.index('icacls')
assert command.index('LASTEXITCODE') < command.index('Invoke-WebRequest')
def test_defaults_are_a_day_and_twenty_five_uses(client, db, auth_headers):
token = _secret(client, auth_headers)['token']
assert token['maxuses'] == 25
created = datetime.datetime.fromisoformat(token['createdat'][:-1])
expires = datetime.datetime.fromisoformat(token['expiresat'][:-1])
assert round((expires - created).total_seconds() / 3600) == 24
@pytest.mark.parametrize('body', [
{'hours': 0}, {'hours': 169}, {'maxuses': 0}, {'maxuses': 501},
{'hours': 'soon'},
])
def test_out_of_range_inputs_are_refused(client, db, auth_headers, body):
assert _mint(client, auth_headers, **body).status_code == 400
def test_list_never_carries_a_secret(client, db, auth_headers):
data = _secret(client, auth_headers)
response = client.get(TOKENS_URL, headers=auth_headers)
assert response.status_code == 200
tokens = response.get_json()['data']['tokens']
assert len(tokens) == 1
assert tokens[0]['tokenprefix'] == data['secret'][:12]
body = response.get_data(as_text=True)
assert data['secret'] not in body
assert 'secret' not in tokens[0]
assert 'tokenhash' not in tokens[0]
def test_revoke_kills_the_token(client, db, auth_headers):
data = _secret(client, auth_headers)
tokenid = data['token']['tokenid']
response = client.post('{0}/{1}/revoke'.format(TOKENS_URL, tokenid),
headers=auth_headers)
assert response.status_code == 200
assert response.get_json()['data']['token']['state'] == 'revoked'
# Idempotent: revoking twice is not an error.
assert client.post('{0}/{1}/revoke'.format(TOKENS_URL, tokenid),
headers=auth_headers).status_code == 200
assert client.post('{0}/999/revoke'.format(TOKENS_URL),
headers=auth_headers).status_code == 404
@pytest.mark.parametrize('method,path', [
('post', TOKENS_URL),
('get', TOKENS_URL),
('post', TOKENS_URL + '/1/revoke'),
])
def test_only_an_admin_can_manage_tokens(client, db, member_headers, method,
path):
anonymous = getattr(client, method)(path)
assert anonymous.status_code in (401, 422)
member = getattr(client, method)(path, headers=member_headers)
assert member.status_code == 403
def test_generate_and_revoke_write_audit_rows(client, db, auth_headers):
data = _secret(client, auth_headers)
client.post('{0}/{1}/revoke'.format(TOKENS_URL, data['token']['tokenid']),
headers=auth_headers)
rows = AuditLog.query.filter_by(
entitytype='EndpointBootstrapToken').order_by(AuditLog.auditlogid).all()
assert [row.action for row in rows] == ['created', 'updated']
assert all(row.entityname == data['secret'][:12] for row in rows)
assert all(row.username == 'testadmin' for row in rows)
# ---------------------------------------------------------------------------
# bootstrap.ps1
# ---------------------------------------------------------------------------
def test_script_is_served_for_a_good_token(client, db, auth_headers):
secret = _secret(client, auth_headers)['secret']
response = client.get(SCRIPT_URL, query_string={'t': secret})
assert response.status_code == 200
assert response.mimetype == 'text/plain'
script = response.get_data(as_text=True)
script.encode('ascii')
assert '"{0}/api/endpoints/siteagent/bootstrap/package"'.format(
BASE_URL) in script
assert 'Run as administrator' in script
assert 'GE-Enforce' in script
assert 'SHOPDB_BOOTSTRAP_FORCE' in script
assert 'Settings > Endpoints > Enrollment' in script
assert 'approved and enrolled' in script
assert 'Get-FileHash' in script
def test_script_bakes_the_staged_binary_hashes(client, db, auth_headers,
tmp_path):
payload = b'MZ' + b'agent-bytes'
traypayload = b'MZ' + b'tray-bytes'
agentstore.store_version('agent', '1.20.0', payload)
agentstore.store_version('tray', '1.7.0', traypayload)
secret = _secret(client, auth_headers)['secret']
script = client.get(SCRIPT_URL,
query_string={'t': secret}).get_data(as_text=True)
assert hashlib.sha256(payload).hexdigest() in script
assert hashlib.sha256(traypayload).hexdigest() in script
def test_the_generated_script_fetches_the_package_with_the_header(
client, db, auth_headers):
"""Same reason as the one-liner: the package URL must not carry the token,
or every agent install writes it into an access log."""
secret = _secret(client, auth_headers)['secret']
script = client.get(SCRIPT_URL,
headers=_token_header(secret)).get_data(as_text=True)
assert '?t=' not in script
assert '$BootstrapPackageHeaders = @{{ "{0}" = "{1}" }}'.format(
TOKEN_HEADER, secret) in script
assert ('Invoke-WebRequest -Uri $BootstrapPackageUrl '
'-Headers $BootstrapPackageHeaders') in script
def test_both_routes_take_the_token_in_a_header(client, db, auth_headers,
servercert):
secret = _secret(client, auth_headers, maxuses=2)['secret']
script = client.get(SCRIPT_URL, headers=_token_header(secret))
assert script.status_code == 200
assert 'ShopDB endpoint agent bootstrap' in script.get_data(as_text=True)
assert 'install.ps1' in _files(
client.get(PACKAGE_URL, headers=_token_header(secret)))
assert EndpointBootstrapToken.query.one().usecount == 1
@pytest.mark.parametrize('url', [SCRIPT_URL, PACKAGE_URL])
def test_a_bad_header_token_is_not_found(client, db, auth_headers, url):
"""The header is read first, so a good ?t= cannot rescue a bad header - one
request carries one token."""
secret = _secret(client, auth_headers)['secret']
response = client.get(url, query_string={'t': secret},
headers=_token_header('sdbb_' + 'a' * 43))
assert response.status_code == 404
def test_the_query_form_still_works_and_is_audited_as_deprecated(
client, db, auth_headers, caplog):
"""A bay running a command pasted before this change must keep working, so
the query form stays accepted; it is flagged in the audit row and warned
about in the log, with no token value in either."""
secret = _secret(client, auth_headers)['secret']
with caplog.at_level(logging.WARNING):
assert client.get(SCRIPT_URL, query_string={'t': secret},
environ_overrides={'REMOTE_ADDR': '10.0.0.6'}
).status_code == 200
row = AuditLog.query.filter_by(entitytype='EndpointBootstrapToken',
action='requested').one()
assert 'query-string token, deprecated' in row.details
assert secret not in row.details
warnings = [record.getMessage() for record in caplog.records
if record.levelno >= logging.WARNING]
assert any('query string' in message and '10.0.0.6' in message
for message in warnings)
assert all(secret not in message for message in warnings)
def test_the_header_form_is_not_flagged_as_deprecated(client, db, auth_headers,
caplog):
secret = _secret(client, auth_headers)['secret']
with caplog.at_level(logging.WARNING):
client.get(SCRIPT_URL, headers=_token_header(secret))
row = AuditLog.query.filter_by(entitytype='EndpointBootstrapToken',
action='requested').one()
assert 'deprecated' not in row.details
assert not [record for record in caplog.records
if record.levelno >= logging.WARNING]
def test_the_admin_download_link_is_not_counted_as_an_un_migrated_bay(
client, db, auth_headers, caplog):
"""The rollout finds un-migrated bays by the deprecation WARNING, and the
UI Download .ps1 link has to use the query form. Marked, it logs at info
and is audited as the admin path, so an admin testing a token does not
invent a bay that is not there."""
data = _secret(client, auth_headers)
path = data['scripturl'].split(BASE_URL, 1)[1]
with caplog.at_level(logging.INFO):
assert client.get(path, environ_overrides={'REMOTE_ADDR': '10.0.0.7'}
).status_code == 200
row = AuditLog.query.filter_by(entitytype='EndpointBootstrapToken',
action='requested').one()
assert 'admin download link' in row.details
assert 'deprecated' not in row.details
assert data['secret'] not in row.details
assert not [record for record in caplog.records
if record.levelno >= logging.WARNING]
infos = [record.getMessage() for record in caplog.records
if record.levelno == logging.INFO]
assert any('Download .ps1' in message and '10.0.0.7' in message
for message in infos)
assert all(data['secret'] not in message for message in infos)
def test_no_log_or_audit_row_ever_carries_the_token_value(
client, db, auth_headers, caplog, servercert):
secret = _secret(client, auth_headers, maxuses=4)['secret']
with caplog.at_level(logging.DEBUG):
client.get(SCRIPT_URL, query_string={'t': secret})
client.get(SCRIPT_URL, headers=_token_header(secret))
client.get(PACKAGE_URL, query_string={'t': secret})
client.get(PACKAGE_URL, headers=_token_header(secret))
assert all(secret not in record.getMessage()
for record in caplog.records)
rows = AuditLog.query.filter_by(
entitytype='EndpointBootstrapToken').all()
assert rows
for row in rows:
assert secret not in (row.details or '')
# The 12-char display prefix is the identifier the UI already shows.
assert row.entityname == secret[:12]
@pytest.mark.parametrize('url', [SCRIPT_URL, PACKAGE_URL])
def test_the_query_form_can_be_turned_off_once_a_site_has_re_issued(
client, db, auth_headers, url):
"""Off is the hardened end state, not the default: the UI download link
uses the query form too, so a site only turns it off deliberately."""
secret = _secret(client, auth_headers)['secret']
Setting.set(QUERY_SETTING, False, valuetype='boolean')
refused = client.get(url, query_string={'t': secret})
assert refused.status_code == 400
assert TOKEN_HEADER in refused.get_json()['data']['error']['message']
# The admin Download .ps1 link is a query token too: the setting is about
# a live token landing in an access log, and that link puts one there.
assert client.get(url, query_string={'t': secret, 'via': 'download'}
).status_code == 400
# Refused before any lookup, so it confirms nothing about the token.
assert EndpointBootstrapToken.query.one().usecount == 0
assert AuditLog.query.filter_by(
entitytype='EndpointBootstrapToken', action='requested').count() == 0
assert client.get(url, headers=_token_header(secret)).status_code == 200
@pytest.mark.parametrize('url', [SCRIPT_URL, PACKAGE_URL])
def test_no_token_is_still_not_found_when_the_query_form_is_off(
client, db, auth_headers, url):
"""A bare GET presented no token, so it is unusable and answers 404 like
every other unusable request. Calling it a query token answered 400 naming
the header, which handed an anonymous caller the state of the switch."""
_secret(client, auth_headers)
Setting.set(QUERY_SETTING, False, valuetype='boolean')
assert client.get(url).status_code == 404
# An empty ?t= presented nothing either.
assert client.get(url, query_string={'t': ''}).status_code == 404
# And it is the same answer with the query form still on.
Setting.set(QUERY_SETTING, True, valuetype='boolean')
assert client.get(url).status_code == 404
def test_script_fetch_does_not_spend_a_use(client, db, auth_headers):
secret = _secret(client, auth_headers, maxuses=1)['secret']
for _ in range(3):
assert client.get(SCRIPT_URL,
query_string={'t': secret}).status_code == 200
row = EndpointBootstrapToken.query.one()
assert row.usecount == 0
def test_script_fetch_is_audited_with_the_caller_ip(client, db, auth_headers):
secret = _secret(client, auth_headers)['secret']
client.get(SCRIPT_URL, query_string={'t': secret},
environ_overrides={'REMOTE_ADDR': '10.0.0.9'})
row = AuditLog.query.filter_by(entitytype='EndpointBootstrapToken',
action='requested').one()
assert row.ipaddress == '10.0.0.9'
assert '10.0.0.9' in row.details
assert row.username is None
# ---------------------------------------------------------------------------
# The package
# ---------------------------------------------------------------------------
def test_package_is_served_and_spends_one_use(client, db, auth_headers,
servercert):
secret = _secret(client, auth_headers, maxuses=2)['secret']
files = _files(client.get(PACKAGE_URL, query_string={'t': secret},
environ_overrides={'REMOTE_ADDR': '10.0.0.8'}))
assert 'install.ps1' in files
assert 'appsettings.json' in files
row = EndpointBootstrapToken.query.one()
assert row.usecount == 1
assert row.lastusedip == '10.0.0.8'
assert row.lastusedat is not None
def test_package_carries_no_collector_key_and_does_carry_server_pins(
client, db, auth_headers, servercert):
secret = _secret(client, auth_headers)['secret']
files = _files(client.get(PACKAGE_URL, query_string={'t': secret}))
for name, content in files.items():
assert b'CollectorKey' not in content, name
script = files['install.ps1'].decode('ascii')
assert '$sharedKeyMode = "omitted"' in script
assert '[string[]] $ServerPins = @("{0}")'.format(servercert) in script
settings = json.loads(files['appsettings.json'])
assert settings['serverpins'] == [servercert]
assert settings['apikey'] == ''
assert b'Settings > Endpoints > Enrollment' in files['README.txt']
def test_package_stays_keyless_even_when_the_shared_key_is_allowed(
client, db, auth_headers, servercert):
Setting.set('plugin.endpoints.sharedkeyallowed', True, valuetype='boolean')
secret = _secret(client, auth_headers)['secret']
files = _files(client.get(PACKAGE_URL, query_string={'t': secret}))
assert b'CollectorKey' not in files['install.ps1']
def test_package_download_is_audited(client, db, auth_headers):
secret = _secret(client, auth_headers)['secret']
client.get(PACKAGE_URL, query_string={'t': secret},
environ_overrides={'REMOTE_ADDR': '10.0.0.7'})
row = AuditLog.query.filter_by(entitytype='EndpointBootstrapToken',
action='downloaded').one()
assert row.ipaddress == '10.0.0.7'
assert 'use 1 of' in row.details
def test_a_token_is_refused_once_exhausted(client, db, auth_headers):
secret = _secret(client, auth_headers, maxuses=1)['secret']
assert client.get(PACKAGE_URL,
query_string={'t': secret}).status_code == 200
assert client.get(PACKAGE_URL,
query_string={'t': secret}).status_code == 404
# The script route is gated on the same usability test.
assert client.get(SCRIPT_URL,
query_string={'t': secret}).status_code == 404
# ---------------------------------------------------------------------------
# Everything unusable answers 404
# ---------------------------------------------------------------------------
@pytest.mark.parametrize('url', [SCRIPT_URL, PACKAGE_URL])
@pytest.mark.parametrize('bad', ['', 'nonsense', 'sdbb_' + 'a' * 43,
'sdbb_short'])
def test_an_unknown_token_is_not_found(client, db, url, bad):
assert client.get(url, query_string={'t': bad}).status_code == 404
@pytest.mark.parametrize('url', [SCRIPT_URL, PACKAGE_URL])
def test_no_token_at_all_is_not_found(client, db, url):
assert client.get(url).status_code == 404
@pytest.mark.parametrize('url', [SCRIPT_URL, PACKAGE_URL])
def test_an_expired_token_is_not_found(client, db, auth_headers, url):
secret = _secret(client, auth_headers, hours=1)['secret']
row = EndpointBootstrapToken.query.one()
row.expiresat = datetime.datetime.utcnow() - datetime.timedelta(minutes=1)
db.session.commit()
assert client.get(url, query_string={'t': secret}).status_code == 404
@pytest.mark.parametrize('url', [SCRIPT_URL, PACKAGE_URL])
def test_a_revoked_token_is_not_found(client, db, auth_headers, url):
data = _secret(client, auth_headers)
client.post('{0}/{1}/revoke'.format(TOKENS_URL, data['token']['tokenid']),
headers=auth_headers)
assert client.get(url, query_string={'t': data['secret']}).status_code == 404
def test_a_bad_token_leaves_no_audit_row(client, db):
client.get(SCRIPT_URL, query_string={'t': 'sdbb_' + 'a' * 43})
assert AuditLog.query.filter_by(
entitytype='EndpointBootstrapToken').count() == 0
# ---------------------------------------------------------------------------
# Rate limiting
# ---------------------------------------------------------------------------
def test_the_rate_limit_triggers_on_both_routes(client, db, app, auth_headers):
secret = _secret(client, auth_headers, maxuses=500)['secret']
app.config['ENDPOINTS_BOOTSTRAP_RATELIMIT_ENABLED'] = True
app.config['ENDPOINTS_BOOTSTRAP_RATELIMIT_MAX'] = 3
app.config['ENDPOINTS_BOOTSTRAP_RATELIMIT_WINDOW_SECONDS'] = 60
caller = {'REMOTE_ADDR': '10.9.9.9'}
try:
codes = [client.get(SCRIPT_URL, query_string={'t': secret},
environ_overrides=caller).status_code
for _ in range(4)]
assert codes == [200, 200, 200, 429]
# The budget is per IP, not per route: the package route is already over.
limited = client.get(PACKAGE_URL, query_string={'t': secret},
environ_overrides=caller)
assert limited.status_code == 429
assert limited.get_json()['data']['error']['code'] == 'RATE_LIMITED'
# A different caller is unaffected.
assert client.get(SCRIPT_URL, query_string={'t': secret},
environ_overrides={'REMOTE_ADDR': '10.9.9.10'}
).status_code == 200
finally:
app.config['ENDPOINTS_BOOTSTRAP_RATELIMIT_ENABLED'] = False
app.config.pop('ENDPOINTS_BOOTSTRAP_RATELIMIT_MAX', None)
app.config.pop('ENDPOINTS_BOOTSTRAP_RATELIMIT_WINDOW_SECONDS', None)
# ---------------------------------------------------------------------------
# Base URL resolution
# ---------------------------------------------------------------------------
def test_the_command_falls_back_to_the_request_when_no_site_url_is_set(
client, db, auth_headers):
Setting.set('site_base_url', '', valuetype='string')
data = _secret(client, auth_headers)
assert data['command'] == _expected_command(data['secret'],
base='https://localhost')
assert data['scripturl'].startswith(
'https://localhost/api/endpoints/siteagent/bootstrap.ps1?t=')
assert data['scripturl'].endswith('&' + UI_LINK_QUERY)

View File

@@ -0,0 +1,107 @@
"""collect_log sensitive-path denylist at enqueue.
The agent refuses answer files, its own install/state and key material. The
server refuses the same set at enqueue so the operator gets a 400 naming the
reason instead of a queued command that fails on the endpoint.
"""
import pytest
from werkzeug.security import generate_password_hash
from plugins.endpoints.api.routes import _validate_log_path
@pytest.mark.parametrize('path', [
r'C:\Windows\Panther\setupact.log',
r'c:/windows/panther/UnattendGC/setupact.log',
r'%SystemRoot%\Panther\setupact.log',
r'%windir%\System32\Sysprep\Panther\setupact.log',
r'D:\WINDOWS\System32\sysprep\x.log',
r'C:\Windows\\Panther\.\setupact.log',
r'C:\ProgramData\ShopDB\state.json',
r'%ProgramData%\shopdb\agent.log',
r'C:\Program Files\ShopDB\EndpointAgent\agent.log',
r'C:\PROGRA~1\ShopDB\EndpointAgent\agent.log',
r'C:\temp\Unattend.xml',
r'C:\temp\unattend-original.xml',
r'C:\temp\AutoUnattend.xml',
r'C:\app\appsettings.json',
r'C:\app\AppSettings.Production.json',
r'C:\certs\server.KEY',
r'C:\certs\client.pfx',
r'C:\certs\client.p12',
])
def test_sensitive_paths_are_refused_with_a_reason(path):
error = _validate_log_path(path)
assert error is not None
assert 'not permitted' in error
@pytest.mark.parametrize('path', [
r'C:\Windows\Logs\CBS\CBS.log',
r'C:\ProgramData\Vendor\app.log',
r'C:\Program Files\Other\settings.json',
r'C:\temp\notunattend.xml',
])
def test_ordinary_log_paths_still_pass(path):
assert _validate_log_path(path) is None
def _endpoint(db, hostname):
from shopdb.api import Asset, AssetType
from plugins.computers.models import Computer
from plugins.endpoints.models import EndpointState
assettype = AssetType.query.filter_by(assettype='computer').first()
if assettype is None:
assettype = AssetType(assettype='computer', pluginname='computers',
tablename='computers')
db.session.add(assettype)
db.session.flush()
asset = Asset(assetnumber=hostname, name=hostname,
assettypeid=assettype.assettypeid, isactive=True)
db.session.add(asset)
db.session.flush()
db.session.add(Computer(assetid=asset.assetid, hostname=hostname))
db.session.add(EndpointState(assetid=asset.assetid, hostname=hostname,
agentversion='1.16.0.0'))
db.session.commit()
return asset.assetid
def _admin(db, client, username='denylistadmin'):
from shopdb.core.models import User, Role, Permission
Permission.seed()
db.session.flush()
role = Role.query.filter_by(rolename='admin').first()
if role is None:
role = Role(rolename='admin')
db.session.add(role)
db.session.commit()
user = User(username=username, email=username + '@test.local',
passwordhash=generate_password_hash('testpass'))
user.roles.append(role)
db.session.add(user)
db.session.commit()
response = client.post('/api/auth/login',
json={'username': username, 'password': 'testpass'})
token = response.get_json()['data']['access_token']
return {'Authorization': f'Bearer {token}'}
def test_enqueue_refuses_an_answer_file_and_queues_nothing(app, client, db):
from plugins.endpoints.models import EndpointCommand
assetid = _endpoint(db, 'denylistbox')
headers = _admin(db, client)
response = client.post(f'/api/endpoints/{assetid}/commands',
json={'action': 'collect_log',
'target': r'%SystemRoot%\Panther\unattend.xml'},
headers=headers)
assert response.status_code == 400
message = response.get_json()['data']['error']['message']
assert 'Panther' in message
assert EndpointCommand.query.filter_by(assetid=assetid).count() == 0

View File

@@ -0,0 +1,525 @@
"""Admin surfaces for per-PC endpoint credentials.
The fleet list, the PC detail status and the dashboard all describe the same
fact - which credential a PC reports with - so they share one state vocabulary:
enrolled, reenrollblocked, pending, notreporting, sharedkey. The list computes
it in SQL so a filter survives pagination; PC detail redacts everything but the
state for a reader below endpoints.fleet; the server certificate live check
reads the host from settings, never from the request.
"""
import hashlib
import os
from datetime import datetime, timedelta, timezone
import pytest
from werkzeug.security import generate_password_hash
from shopdb.core.models import Asset, AssetType, Setting
def _now():
return datetime.now(timezone.utc).replace(tzinfo=None)
def _pc(db, hostname, reported=True, serialnumber='SER'):
from plugins.computers.models import Computer
from plugins.endpoints.models import EndpointState
atype = AssetType.query.filter_by(assettype='computer').first()
if atype is None:
atype = AssetType(assettype='computer', pluginname='computers',
tablename='computers')
db.session.add(atype)
db.session.flush()
asset = Asset(assetnumber=hostname, name=hostname, serialnumber=serialnumber,
assettypeid=atype.assettypeid, isactive=True)
db.session.add(asset)
db.session.flush()
computer = Computer(assetid=asset.assetid, hostname=hostname)
db.session.add(computer)
db.session.add(EndpointState(
assetid=asset.assetid, hostname=hostname, agentversion='1.20.0',
lastreportedat=_now() if reported else None))
db.session.commit()
return asset.assetid
def _credential(db, assetid, revoked=False, blocked=False, reason=None,
revokedat=None, prefix='sdbe_abcdefg'):
from plugins.endpoints.models import EndpointCredential
row = EndpointCredential(
assetid=assetid, credentialhash=hashlib.sha256(
str(assetid).encode()).hexdigest(),
credentialprefix=prefix, enrolledvia='auto', issuedat=_now(),
lastusedat=_now(),
revokedat=(revokedat or _now()) if revoked else None,
revokedby='admin' if revoked else None,
reenrollblocked=blocked, reenrollblockedreason=reason)
db.session.add(row)
db.session.commit()
return row
def _request(db, hostname, matchedassetid=None, reason='awaiting approval',
status='pending'):
from plugins.endpoints.models import EndpointEnrollRequest
row = EndpointEnrollRequest(
hostname=hostname, serialnumber='SER', enrollnonce='n' * 40,
status=status, reason=reason, matchedassetid=matchedassetid,
requestedat=_now(), lastseenat=_now())
db.session.add(row)
db.session.commit()
return row
def _login(db, client, username, permissions=()):
from shopdb.core.models import User, Role, Permission
Permission.seed()
db.session.flush()
role = Role(rolename=username + 'role')
db.session.add(role)
db.session.flush()
for name in permissions:
perm = Permission.query.filter_by(name=name).first()
assert perm is not None, name
role.permissions.append(perm)
user = User(username=username, email=username + '@test.local',
passwordhash=generate_password_hash('testpass'))
user.roles.append(role)
db.session.add(user)
db.session.commit()
response = client.post('/api/auth/login',
json={'username': username, 'password': 'testpass'})
return {'Authorization': 'Bearer ' + response.get_json()['data']['access_token']}
@pytest.fixture
def fleet(db):
"""One PC in every credential state."""
ids = {
'ENROLLED': _pc(db, 'ENROLLED'),
'BLOCKED': _pc(db, 'BLOCKED'),
'PENDING': _pc(db, 'PENDING'),
'SILENT': _pc(db, 'SILENT', reported=False),
'SHARED': _pc(db, 'SHARED'),
'REVOKED': _pc(db, 'REVOKED'),
}
_credential(db, ids['ENROLLED'])
_credential(db, ids['BLOCKED'], revoked=True, blocked=True, reason='rollback')
# A blocked PC keeps asking; its request must not turn it into pending.
_request(db, 'BLOCKED', ids['BLOCKED'], reason='reenroll blocked')
_request(db, 'pending')
_credential(db, ids['REVOKED'], revoked=True)
return ids
EXPECTED = {
'ENROLLED': 'enrolled',
'BLOCKED': 'reenrollblocked',
'PENDING': 'pending',
'SILENT': 'notreporting',
'SHARED': 'sharedkey',
'REVOKED': 'sharedkey',
}
# -- fleet list ---------------------------------------------------------------
def test_fleet_list_carries_the_credential_state(client, db, auth_headers, fleet):
response = client.get('/api/endpoints?perpage=100', headers=auth_headers)
assert response.status_code == 200, response.get_data(as_text=True)
states = {row['hostname']: row['credentialstate']
for row in response.get_json()['data']}
assert states == EXPECTED
@pytest.mark.parametrize('state', sorted(set(EXPECTED.values())))
def test_fleet_list_filters_by_credential_state(client, db, auth_headers, fleet,
state):
response = client.get(f'/api/endpoints?credentialstate={state}',
headers=auth_headers)
assert response.status_code == 200
body = response.get_json()
hosts = sorted(row['hostname'] for row in body['data'])
assert hosts == sorted(h for h, s in EXPECTED.items() if s == state)
assert body['meta']['pagination']['total'] == len(hosts)
def test_credential_filter_keeps_paging(client, db, auth_headers, fleet):
response = client.get('/api/endpoints?credentialstate=sharedkey&perpage=1'
'&sort=hostname', headers=auth_headers)
body = response.get_json()
assert [row['hostname'] for row in body['data']] == ['REVOKED']
assert body['meta']['pagination']['total'] == 2
def test_fleet_list_state_costs_no_query_per_row(client, db, app, auth_headers,
fleet):
from sqlalchemy import event
statements = []
def record(_connection, _cursor, statement, *_args):
if 'endpointcredentials' in statement or 'endpointenrollrequests' in statement:
statements.append(statement)
engine = db.engine
event.listen(engine, 'before_cursor_execute', record)
try:
client.get('/api/endpoints?perpage=100', headers=auth_headers)
small = len(statements)
for index in range(10):
_pc(db, f'EXTRA-{index:02d}')
statements.clear()
client.get('/api/endpoints?perpage=100', headers=auth_headers)
large = len(statements)
finally:
event.remove(engine, 'before_cursor_execute', record)
assert small == large
assert large <= 2
def test_unknown_credential_filter_is_ignored_like_status(client, db,
auth_headers, fleet):
response = client.get('/api/endpoints?credentialstate=bogus',
headers=auth_headers)
assert response.status_code == 200
assert len(response.get_json()['data']) == len(EXPECTED)
# -- PC detail ----------------------------------------------------------------
def test_endpoint_detail_credential_for_fleet_holders(client, db, auth_headers,
fleet):
data = client.get(f"/api/endpoints/{fleet['BLOCKED']}",
headers=auth_headers).get_json()['data']
credential = data['credential']
assert credential['state'] == 'reenrollblocked'
assert credential['statelabel'] == 'Re-enroll blocked'
assert credential['credentialprefix'] == 'sdbe_abcdefg'
assert credential['issuedat'] and credential['lastusedat']
assert credential['enrolledvia'] == 'auto'
assert credential['reenrollblocked'] is True
assert credential['reenrollblockedreason'] == 'rollback'
@pytest.mark.parametrize('hostname', sorted(EXPECTED))
def test_endpoint_detail_state_matches_the_list(client, db, auth_headers, fleet,
hostname):
data = client.get(f'/api/endpoints/{fleet[hostname]}',
headers=auth_headers).get_json()['data']
assert data['credential']['state'] == EXPECTED[hostname]
def test_endpoint_detail_redacts_below_fleet(client, db, fleet):
headers = _login(db, client, 'viewer', ['endpoints.view'])
response = client.get(f"/api/endpoints/{fleet['ENROLLED']}", headers=headers)
assert response.status_code == 200
credential = response.get_json()['data']['credential']
assert credential['state'] == 'enrolled'
for key in ('credentialprefix', 'issuedat', 'lastusedat', 'revokedat',
'enrolledvia', 'reenrollblockedreason'):
assert credential[key] is None, key
panel = client.get(f"/api/endpoints/asset/{fleet['ENROLLED']}/status",
headers=headers).get_json()['data']
labels = {field['label']: field['value'] for field in panel['fields']}
assert labels['Credential'] == 'Enrolled'
assert 'Credential prefix' not in labels
assert 'Credential issued' not in labels
assert 'Credential last used' not in labels
def test_guest_with_endpoints_view_sees_state_only(client, db, fleet):
from shopdb.core.models import Permission, Role
Permission.seed()
Role.seedguest()
db.session.commit()
guest = Role.guest()
guest.permissions = Permission.query.filter_by(name='endpoints.view').all()
db.session.commit()
response = client.get(f"/api/endpoints/asset/{fleet['BLOCKED']}/status")
assert response.status_code == 200, response.get_data(as_text=True)
data = response.get_json()['data']
labels = {field['label']: field['value'] for field in data['fields']}
assert labels['Credential'] == 'Re-enroll blocked'
assert 'Re-enroll block reason' not in labels
assert data['credential']['credentialprefix'] is None
assert data['credential']['reenrollblockedreason'] is None
def test_status_panel_shows_credential_fields_to_fleet_holders(client, db,
auth_headers,
fleet):
panel = client.get(f"/api/endpoints/asset/{fleet['BLOCKED']}/status",
headers=auth_headers).get_json()['data']
labels = {field['label']: field for field in panel['fields']}
assert labels['Credential']['value'] == 'Re-enroll blocked'
assert labels['Credential prefix']['value'] == 'sdbe_abcdefg...'
assert labels['Credential prefix']['mono'] is True
assert labels['Credential issued']['format'] == 'datetime'
assert labels['Credential last used']['format'] == 'datetime'
assert labels['Enrolled via']['value'] == 'auto'
assert labels['Re-enroll block reason']['value'] == 'rollback'
# -- server certificate live check -------------------------------------------
def _certificate_pem_and_der():
from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.x509.oid import NameOID
key = ec.generate_private_key(ec.SECP256R1())
name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, 'live.example.test')])
now = datetime.now(timezone.utc)
certificate = (x509.CertificateBuilder()
.subject_name(name).issuer_name(name)
.public_key(key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(now - timedelta(days=1))
.not_valid_after(now + timedelta(days=30))
.sign(key, hashes.SHA256()))
spki = key.public_key().public_bytes(
serialization.Encoding.DER,
serialization.PublicFormat.SubjectPublicKeyInfo)
return (certificate.public_bytes(serialization.Encoding.PEM),
certificate.public_bytes(serialization.Encoding.DER),
hashlib.sha256(spki).hexdigest(),
certificate.fingerprint(hashes.SHA1()).hex().upper())
class _FakeSocket:
def __init__(self, der=None):
self.der = der
def __enter__(self):
return self
def __exit__(self, *_args):
return False
def getpeercert(self, binary_form=False):
assert binary_form is True
return self.der
@pytest.fixture
def tls(monkeypatch, tmp_path):
"""Replace the socket and ssl layer; record what the route connected to."""
import socket
import ssl
from plugins.endpoints.api import routes as endpoint_routes
certdir = tmp_path / 'certs'
certdir.mkdir()
monkeypatch.setattr(endpoint_routes, '_server_cert_dir', lambda: certdir)
state = {'der': None, 'connected': [], 'wrapped': [], 'error': None}
def create_connection(address, timeout=None):
state['connected'].append((address, timeout))
if state['error'] is not None:
raise state['error']
return _FakeSocket()
def wrap_socket(self, sock, server_hostname=None, **_kwargs):
state['wrapped'].append((server_hostname, self.verify_mode,
self.check_hostname))
return _FakeSocket(state['der'])
monkeypatch.setattr(socket, 'create_connection', create_connection)
monkeypatch.setattr(ssl.SSLContext, 'wrap_socket', wrap_socket)
state['certdir'] = certdir
return state
LIVE_URL = '/api/endpoints/settings/server-cert/live'
def test_live_check_unconfigured(client, db, auth_headers, tls):
response = client.get(LIVE_URL, headers=auth_headers)
assert response.status_code == 200
assert response.get_json()['data'] == {'configured': False}
assert tls['connected'] == []
def test_live_check_matches_the_file(client, db, auth_headers, tls):
import ssl
pem, der, pin, sha1 = _certificate_pem_and_der()
(tls['certdir'] / 'server.crt').write_bytes(pem)
tls['der'] = der
Setting.set('site_base_url', 'https://shopdb.example.test:8443/shopdb')
db.session.commit()
response = client.get(LIVE_URL, headers={**auth_headers,
'Host': 'attacker.example'})
assert response.status_code == 200, response.get_data(as_text=True)
data = response.get_json()['data']
assert data == {
'configured': True,
'url': 'https://shopdb.example.test:8443/shopdb',
'presentedspkisha256': pin,
'presentedsha1thumbprint': sha1,
'matchesfile': True,
'error': None,
}
# Host from the setting, never the request; 5 s; read-only handshake.
assert tls['connected'] == [(('shopdb.example.test', 8443), 5)]
assert tls['wrapped'] == [('shopdb.example.test', ssl.CERT_NONE, False)]
def test_live_check_reports_a_different_certificate(client, db, auth_headers,
tls):
filepem, _der, filepin, _sha1 = _certificate_pem_and_der()
_pem, liveder, livepin, _livesha1 = _certificate_pem_and_der()
(tls['certdir'] / 'server.crt').write_bytes(filepem)
tls['der'] = liveder
Setting.set('site_base_url', 'https://shopdb.example.test')
db.session.commit()
data = client.get(LIVE_URL, headers=auth_headers).get_json()['data']
assert data['presentedspkisha256'] == livepin != filepin
assert data['matchesfile'] is False
assert data['error'] is None
assert tls['connected'][0][0] == ('shopdb.example.test', 443)
def test_live_check_no_file_never_matches(client, db, auth_headers, tls):
_pem, der, pin, _sha1 = _certificate_pem_and_der()
tls['der'] = der
Setting.set('site_base_url', 'https://10.0.0.5')
db.session.commit()
data = client.get(LIVE_URL, headers=auth_headers).get_json()['data']
assert data['presentedspkisha256'] == pin
assert data['matchesfile'] is False
# No SNI for an address.
assert tls['wrapped'][0][0] is None
def test_live_check_connection_failure(client, db, auth_headers, tls):
tls['error'] = OSError('Connection refused')
Setting.set('site_base_url', 'https://shopdb.example.test')
db.session.commit()
response = client.get(LIVE_URL, headers=auth_headers)
assert response.status_code == 200
data = response.get_json()['data']
assert data['configured'] is True
assert data['presentedspkisha256'] is None
assert data['matchesfile'] is False
assert 'Connection refused' in data['error']
def test_live_check_refuses_plain_http(client, db, auth_headers, tls):
Setting.set('site_base_url', 'http://shopdb.example.test')
db.session.commit()
data = client.get(LIVE_URL, headers=auth_headers).get_json()['data']
assert data['configured'] is True
assert 'https' in data['error']
assert tls['connected'] == []
def test_live_check_allows_fleet_holders(client, db, tls):
headers = _login(db, client, 'fleetreader', ['endpoints.fleet'])
assert client.get(LIVE_URL, headers=headers).status_code == 200
# -- dashboard ----------------------------------------------------------------
def test_pending_enrollment_attention_row(client, db, auth_headers, fleet):
response = client.get('/api/endpoints/dashboard/enrollmentpending',
headers=auth_headers)
assert response.status_code == 200
# The blocked PC's own request is expected noise, not work.
assert response.get_json()['data'] == [
{'summary': '1 PC waiting for enrollment approval', 'pendingcount': 1}]
_request(db, 'ANOTHER')
data = client.get('/api/endpoints/dashboard/enrollmentpending',
headers=auth_headers).get_json()['data']
assert data[0]['summary'] == '2 PCs waiting for enrollment approval'
def test_pending_enrollment_attention_hides_when_nothing_waits(client, db,
auth_headers):
response = client.get('/api/endpoints/dashboard/enrollmentpending',
headers=auth_headers)
assert response.get_json()['data'] == []
def test_reenroll_blocked_attention_after_seven_days(client, db, auth_headers):
from plugins.computers.models import Computer
fresh = _pc(db, 'FRESH')
stale = _pc(db, 'STALE')
_credential(db, fresh, revoked=True, blocked=True,
revokedat=_now() - timedelta(days=2))
_credential(db, stale, revoked=True, blocked=True, reason='rollback',
revokedat=_now() - timedelta(days=9))
data = client.get('/api/endpoints/dashboard/reenrollblocked',
headers=auth_headers).get_json()['data']
computerid = Computer.query.filter_by(assetid=stale).one().computerid
assert data == [{'computerid': computerid, 'assetid': stale,
'hostname': 'STALE', 'blockeddays': 9,
'reason': 'rollback'}]
@pytest.mark.parametrize('url', [
'/api/endpoints/dashboard/enrollmentpending',
'/api/endpoints/dashboard/reenrollblocked',
LIVE_URL,
])
def test_new_reads_need_a_permission(client, db, member_headers, url):
assert client.get(url, headers=member_headers).status_code == 403
assert client.get(url).status_code == 401
def test_dashboard_widgets_declare_the_enrollment_items():
from plugins.endpoints.plugin import EndpointsPlugin
widgets = {w['id']: w for w in EndpointsPlugin().get_dashboard_widgets()}
pending = widgets['endpoints-enrollment-pending']
assert pending['severity'] == 'warning'
assert pending['endpoint'] == '/api/endpoints/dashboard/enrollmentpending'
assert pending['map']['link'] == '/settings/endpoints/enrollment'
blocked = widgets['endpoints-reenroll-blocked']
assert blocked['severity'] == 'info'
assert blocked['map']['link'] == '/pcs/{computerid}'
for widget in (pending, blocked):
assert widget['permission'] == 'endpoints.fleet'
assert widget['empty'] == 'hide'
# -- settings cards -----------------------------------------------------------
def test_settings_cards_describe_the_new_model_and_their_icons_render():
from plugins.endpoints.plugin import EndpointsPlugin
cards = {card['to']: card for card in EndpointsPlugin().get_settings_cards()}
cert = cards['/settings/endpoints/server-cert']
assert 'nginx' not in cert['description'].lower()
assert 'IIS' in cert['description'] and 'Docker' in cert['description']
assert 'shared' in cards['/settings/endpoints/agentkey']['description']
enrollment = cards['/settings/endpoints/enrollment']
assert enrollment['icon'] != cards['/settings/endpoints/agentkey']['icon']
catalog = os.path.join(os.path.dirname(__file__), '..', '..', 'frontend',
'src', 'composables', 'settingsCatalog.js')
with open(os.path.normpath(catalog), encoding='utf-8') as handle:
source = handle.read()
for card in cards.values():
icon = card['icon']
assert f"'{icon}':" in source or f" {icon}:" in source, icon

View File

@@ -0,0 +1,935 @@
"""Per-PC endpoint credentials: enrollment, authentication and identity binding.
A PC enrolls once on the shared fleet key and gets its own secret. From then on
the server derives the calling asset from that secret, so a request naming a
different PC is refused (ASSET_MISMATCH), the shared key is refused for that PC
(SHARED_KEY_REFUSED), and a revoked secret is refused outright
(CREDENTIAL_INVALID) so the agent knows to enroll again.
"""
import pytest
from shopdb.core.models import Asset, AssetType, AuditLog, Setting
COLLECTOR_KEY = 'testcollectorkey'
ENROLL_URL = '/api/endpoints/enroll'
NONCE = 'n' * 40
OTHER_NONCE = 'm' * 40
@pytest.fixture
def keyheaders(app):
app.config['COLLECTOR_API_KEY'] = COLLECTOR_KEY
return {'X-API-Key': COLLECTOR_KEY}
def _computer(db, hostname, serialnumber='SER-1'):
from plugins.computers.models import Computer
atype = AssetType.query.filter_by(assettype='computer').first()
if atype is None:
atype = AssetType(assettype='computer')
db.session.add(atype)
db.session.flush()
asset = Asset(assetnumber=hostname, assettypeid=atype.assettypeid,
serialnumber=serialnumber, isactive=True)
db.session.add(asset)
db.session.flush()
db.session.add(Computer(assetid=asset.assetid, hostname=hostname))
db.session.commit()
return asset.assetid
def _enroll_body(hostname='BAY-01', serialnumber='SER-1', nonce=NONCE):
return {'hostname': hostname, 'serialnumber': serialnumber,
'deviceid': None, 'enrollnonce': nonce, 'agentversion': '1.20.0'}
def _enroll(client, headers, **kwargs):
return client.post(ENROLL_URL, json=_enroll_body(**kwargs), headers=headers)
def _credential(client, keyheaders, **kwargs):
response = _enroll(client, keyheaders, **kwargs)
assert response.status_code == 201, response.get_data(as_text=True)
return response.get_json()['data']['credential']
def _setting(db, key, value):
Setting.set(key, value, valuetype='boolean', category='plugin.endpoints')
db.session.commit()
def _code(response):
return response.get_json()['data']['error']['code']
# -- enroll ------------------------------------------------------------------
def test_auto_enroll_issues_a_credential_once(client, db, app, keyheaders):
from plugins.endpoints.models import EndpointCredential
assetid = _computer(db, 'BAY-01')
response = _enroll(client, keyheaders)
assert response.status_code == 201, response.get_data(as_text=True)
data = response.get_json()['data']
assert data['status'] == 'enrolled'
assert data['assetid'] == assetid
assert data['credential'].startswith('sdbe_')
assert len(data['credential']) == 48
row = EndpointCredential.query.filter_by(assetid=assetid).one()
assert row.enrolledvia == 'auto'
assert row.credentialprefix == data['credential'][:12]
# Hash only, never the secret.
assert data['credential'] not in (row.credentialhash, row.credentialprefix)
again = _enroll(client, keyheaders)
assert again.status_code == 202
assert again.get_json()['data']['reason'] == 'already enrolled'
@pytest.mark.parametrize('setup,body,reason', [
('none', {}, 'unknown hostname'),
('duplicate', {}, 'duplicate hostname'),
('noserial', {}, 'no serial on record'),
('serial', {'serialnumber': 'OTHER'}, 'serial mismatch'),
('noauto', {}, 'awaiting approval'),
])
def test_each_pending_reason(client, db, app, keyheaders, setup, body, reason):
if setup == 'duplicate':
_computer(db, 'BAY-01')
_computer(db, 'bay-01')
elif setup == 'noserial':
_computer(db, 'BAY-01', serialnumber=None)
elif setup in ('serial', 'noauto'):
_computer(db, 'BAY-01')
if setup == 'noauto':
_setting(db, 'plugin.endpoints.autoenroll', False)
response = _enroll(client, keyheaders, **body)
assert response.status_code == 202, response.get_data(as_text=True)
data = response.get_json()['data']
assert data['status'] == 'pending'
assert data['reason'] == reason
assert data['requestid']
def test_serial_compare_ignores_case_and_whitespace(client, db, app, keyheaders):
_computer(db, 'BAY-01', serialnumber=' ser-1 ')
assert _enroll(client, keyheaders, serialnumber='SER-1').status_code == 201
def test_enroll_validates_the_nonce(client, db, app, keyheaders):
_computer(db, 'BAY-01')
for nonce in ('short', 'x' * 129, 'bad nonce ' * 4):
response = _enroll(client, keyheaders, nonce=nonce)
assert response.status_code == 400, nonce
@pytest.mark.parametrize('headers', [
{},
{'X-API-Key': 'not-the-key'},
])
def test_enroll_without_a_valid_key_records_a_pending_request(
client, db, app, keyheaders, auth_headers, caplog, headers):
from plugins.endpoints.models import EndpointCredential, EndpointEnrollRequest
assetid = _computer(db, 'BAY-01')
response = _enroll(client, headers)
assert response.status_code == 202, response.get_data(as_text=True)
data = response.get_json()['data']
assert data['status'] == 'pending'
assert data['reason'] == 'awaiting approval'
assert EndpointCredential.query.filter_by(assetid=assetid).first() is None
assert ('invalid API key' in caplog.text) == bool(headers)
# Retrying updates the same row; it never piles up rows.
assert _enroll(client, headers).status_code == 202
assert EndpointEnrollRequest.query.count() == 1
listing = client.get('/api/endpoints/enrollment',
headers=auth_headers).get_json()['data']
assert [(r['requestid'], r['status']) for r in listing['requests']] == [
(data['requestid'], 'pending')]
assert listing['counts']['pending'] == 1
def test_enroll_with_no_key_for_an_unknown_hostname_is_pending(
client, db, app, keyheaders):
response = _enroll(client, {}, hostname='NEW-PC')
assert response.status_code == 202, response.get_data(as_text=True)
assert response.get_json()['data']['reason'] == 'unknown hostname'
def test_approved_request_issues_to_its_nonce_without_a_key(
client, db, app, keyheaders, auth_headers):
from plugins.endpoints.models import EndpointCredential
assetid = _computer(db, 'BAY-01')
pending = _enroll(client, {})
requestid = pending.get_json()['data']['requestid']
approve = client.post(
f'/api/endpoints/enrollment/requests/{requestid}/approve',
json={'assetid': assetid}, headers=auth_headers)
assert approve.status_code == 200, approve.get_data(as_text=True)
wrong = _enroll(client, {}, nonce=OTHER_NONCE)
assert wrong.status_code == 403
assert _code(wrong) == 'ENROLL_NONCE_MISMATCH'
issued = _enroll(client, {})
assert issued.status_code == 201, issued.get_data(as_text=True)
assert EndpointCredential.query.filter_by(assetid=assetid).one() \
.enrolledvia == 'approved'
def test_a_second_nonce_on_a_pending_request_stays_pending(client, db, app,
keyheaders):
from plugins.endpoints.models import EndpointEnrollRequest
_computer(db, 'BAY-01')
_setting(db, 'plugin.endpoints.autoenroll', False)
first = _enroll(client, keyheaders)
assert first.status_code == 202
# Auto-enroll back on: a different installation still does not get through.
_setting(db, 'plugin.endpoints.autoenroll', True)
second = _enroll(client, keyheaders, nonce=OTHER_NONCE)
assert second.status_code == 202
assert second.get_json()['data']['reason'] == 'nonce changed'
row = db.session.get(EndpointEnrollRequest,
first.get_json()['data']['requestid'])
assert row.enrollnonce == NONCE
def test_approved_request_issues_only_to_its_nonce(client, db, app, keyheaders,
auth_headers):
from plugins.endpoints.models import EndpointCredential
assetid = _computer(db, 'BAY-01')
_setting(db, 'plugin.endpoints.autoenroll', False)
pending = _enroll(client, keyheaders)
requestid = pending.get_json()['data']['requestid']
approve = client.post(
f'/api/endpoints/enrollment/requests/{requestid}/approve',
json={'assetid': assetid}, headers=auth_headers)
assert approve.status_code == 200, approve.get_data(as_text=True)
wrong = _enroll(client, keyheaders, nonce=OTHER_NONCE)
assert wrong.status_code == 403
assert _code(wrong) == 'ENROLL_NONCE_MISMATCH'
right = _enroll(client, keyheaders)
assert right.status_code == 201, right.get_data(as_text=True)
assert EndpointCredential.query.filter_by(assetid=assetid).one() \
.enrolledvia == 'approved'
def test_shared_key_off_enroll_takes_no_auth_but_never_auto_enrolls(
client, db, app, keyheaders, auth_headers):
assetid = _computer(db, 'BAY-01')
_setting(db, 'plugin.endpoints.sharedkeyallowed', False)
pending = client.post(ENROLL_URL, json=_enroll_body())
assert pending.status_code == 202, pending.get_data(as_text=True)
assert pending.get_json()['data']['reason'] == 'awaiting approval'
requestid = pending.get_json()['data']['requestid']
client.post(f'/api/endpoints/enrollment/requests/{requestid}/approve',
json={'assetid': assetid}, headers=auth_headers)
issued = client.post(ENROLL_URL, json=_enroll_body())
assert issued.status_code == 201
def test_rejected_request_stays_rejected_for_the_same_nonce(
client, db, app, keyheaders, auth_headers):
_computer(db, 'BAY-01', serialnumber='SER-9')
pending = _enroll(client, keyheaders)
requestid = pending.get_json()['data']['requestid']
client.post(f'/api/endpoints/enrollment/requests/{requestid}/reject',
headers=auth_headers)
again = _enroll(client, keyheaders)
assert again.status_code == 202
assert again.get_json()['data']['reason'] == 'rejected'
# -- agent auth --------------------------------------------------------------
def test_shared_key_is_refused_after_enrollment(client, db, app, keyheaders):
_computer(db, 'BAY-01')
secret = _credential(client, keyheaders)
refused = client.get('/api/endpoints/alerts?hostname=BAY-01',
headers=keyheaders)
assert refused.status_code == 401
assert _code(refused) == 'SHARED_KEY_REFUSED'
ok = client.get('/api/endpoints/alerts',
headers={'X-Endpoint-Credential': secret})
assert ok.status_code == 200, ok.get_data(as_text=True)
def test_shared_key_still_works_for_a_pc_that_has_not_enrolled(
client, db, app, keyheaders):
_computer(db, 'BAY-01')
_computer(db, 'BAY-02')
_credential(client, keyheaders)
response = client.get('/api/endpoints/alerts?hostname=BAY-02',
headers=keyheaders)
assert response.status_code == 200
def test_shared_key_switched_off_is_refused(client, db, app, keyheaders):
_computer(db, 'BAY-01')
_setting(db, 'plugin.endpoints.sharedkeyallowed', False)
response = client.get('/api/endpoints/alerts?hostname=BAY-01',
headers=keyheaders)
assert response.status_code == 401
assert _code(response) == 'SHARED_KEY_REFUSED'
def test_credential_is_invalid_after_revoke(client, db, app, keyheaders,
auth_headers):
assetid = _computer(db, 'BAY-01')
secret = _credential(client, keyheaders)
revoke = client.post(f'/api/endpoints/enrollment/assets/{assetid}/revoke',
headers=auth_headers)
assert revoke.status_code == 200, revoke.get_data(as_text=True)
response = client.get('/api/endpoints/alerts',
headers={'X-Endpoint-Credential': secret})
assert response.status_code == 401
assert _code(response) == 'CREDENTIAL_INVALID'
# Revoked: the shared key is admitted again and the PC can re-enroll.
assert client.get('/api/endpoints/alerts?hostname=BAY-01',
headers=keyheaders).status_code == 200
reissued = _credential(client, keyheaders)
assert reissued != secret
def test_revoke_with_block_keeps_the_pc_off_credentials(client, db, app,
keyheaders,
auth_headers):
from plugins.endpoints.models import EndpointCredential
assetid = _computer(db, 'BAY-01')
secret = _credential(client, keyheaders)
revoke = client.post(f'/api/endpoints/enrollment/assets/{assetid}/revoke',
json={'blockreenroll': True, 'reason': 'rollback'},
headers=auth_headers)
assert revoke.status_code == 200, revoke.get_data(as_text=True)
data = revoke.get_json()['data']
assert data['revokedat'] is not None
assert data['reenrollblocked'] is True
assert data['reenrollblockedreason'] == 'rollback'
# The current agent asks again at once: refused, with the reason.
again = _enroll(client, keyheaders)
assert again.status_code == 202, again.get_data(as_text=True)
assert again.get_json()['data']['reason'] == 'reenroll blocked'
assert client.get('/api/endpoints/alerts',
headers={'X-Endpoint-Credential': secret}).status_code == 401
# An older agent on the shared key keeps working.
assert client.get('/api/endpoints/alerts?hostname=BAY-01',
headers=keyheaders).status_code == 200
listing = client.get('/api/endpoints/enrollment',
headers=auth_headers).get_json()['data']
row = next(r for r in listing['computers'] if r['assetid'] == assetid)
assert row['reenrollblocked'] is True
assert row['reenrollblockedreason'] == 'rollback'
assert row['state'] == 'revoked'
# Blocked also means an approval cannot hand one out.
requestid = again.get_json()['data']['requestid']
approve = client.post(
f'/api/endpoints/enrollment/requests/{requestid}/approve',
json={'assetid': assetid}, headers=auth_headers)
assert approve.status_code == 409
allow = client.post(
f'/api/endpoints/enrollment/assets/{assetid}/allow-reenroll',
headers=auth_headers)
assert allow.status_code == 200, allow.get_data(as_text=True)
assert allow.get_json()['data']['reenrollblocked'] is False
row = EndpointCredential.query.filter_by(assetid=assetid).one()
assert row.reenrollblockedreason is None
reissued = _credential(client, keyheaders)
assert reissued != secret
def test_blocked_pc_under_shared_key_off_is_refused_everything(
client, db, app, keyheaders, auth_headers):
assetid = _computer(db, 'BAY-01')
_credential(client, keyheaders)
client.post(f'/api/endpoints/enrollment/assets/{assetid}/revoke',
json={'blockreenroll': True}, headers=auth_headers)
_setting(db, 'plugin.endpoints.sharedkeyallowed', False)
refused = client.get('/api/endpoints/alerts?hostname=BAY-01',
headers=keyheaders)
assert refused.status_code == 401
assert _code(refused) == 'SHARED_KEY_REFUSED'
def test_revoke_block_validation_and_revoked_rows(client, db, app, keyheaders,
auth_headers):
assetid = _computer(db, 'BAY-01')
nocredential = _computer(db, 'BAY-02', serialnumber='SER-2')
url = f'/api/endpoints/enrollment/assets/{assetid}/revoke'
_credential(client, keyheaders)
assert client.post(url, json={'blockreenroll': 'yes'},
headers=auth_headers).status_code == 400
assert client.post(url, json={'blockreenroll': True, 'reason': 'x' * 65},
headers=auth_headers).status_code == 400
# Plain revoke, then block the already-revoked credential afterwards.
assert client.post(url, headers=auth_headers).status_code == 200
assert client.post(url, headers=auth_headers).status_code == 404
blocked = client.post(url, json={'blockreenroll': True},
headers=auth_headers)
assert blocked.status_code == 200
assert blocked.get_json()['data']['reenrollblocked'] is True
assert client.post(
f'/api/endpoints/enrollment/assets/{nocredential}/revoke',
json={'blockreenroll': True}, headers=auth_headers).status_code == 404
assert client.post(
f'/api/endpoints/enrollment/assets/{nocredential}/allow-reenroll',
headers=auth_headers).status_code == 404
def test_garbage_credential_is_invalid(client, db, app):
response = client.get('/api/endpoints/commands/next',
headers={'X-Endpoint-Credential': 'sdbe_nope'})
assert response.status_code == 401
assert _code(response) == 'CREDENTIAL_INVALID'
def test_package_downloads_need_no_identity(client, db, app, keyheaders):
_computer(db, 'BAY-01')
secret = _credential(client, keyheaders)
response = client.get('/api/endpoints/scriptbody/999',
headers={'X-Endpoint-Credential': secret})
assert response.status_code == 404
# -- ASSET_MISMATCH ----------------------------------------------------------
def test_commands_next_refuses_another_hostname(client, db, app, keyheaders):
_computer(db, 'BAY-01')
_computer(db, 'BAY-02', serialnumber='SER-2')
secret = _credential(client, keyheaders)
response = client.get('/api/endpoints/commands/next?hostname=BAY-02',
headers={'X-Endpoint-Credential': secret})
assert response.status_code == 403
assert _code(response) == 'ASSET_MISMATCH'
def test_commands_next_claims_for_the_credential_asset(client, db, app,
keyheaders):
from plugins.endpoints.models import EndpointCommand
assetid = _computer(db, 'BAY-01')
secret = _credential(client, keyheaders)
db.session.add(EndpointCommand(assetid=assetid, action='mtconnect_status',
status=EndpointCommand.STATUS_PENDING))
db.session.commit()
response = client.get('/api/endpoints/commands/next',
headers={'X-Endpoint-Credential': secret})
assert response.status_code == 200, response.get_data(as_text=True)
assert response.get_json()['data']['action'] == 'mtconnect_status'
def test_command_result_refuses_another_assets_command(client, db, app,
keyheaders):
from plugins.endpoints.models import EndpointCommand
_computer(db, 'BAY-01')
other = _computer(db, 'BAY-02', serialnumber='SER-2')
secret = _credential(client, keyheaders)
command = EndpointCommand(assetid=other, action='mtconnect_status',
status=EndpointCommand.STATUS_RUNNING)
db.session.add(command)
db.session.commit()
response = client.post(
f'/api/endpoints/commands/{command.endpointcommandid}/result',
json={'succeeded': True},
headers={'X-Endpoint-Credential': secret})
assert response.status_code == 403
assert _code(response) == 'ASSET_MISMATCH'
def test_command_result_for_own_command_needs_no_hostname(client, db, app,
keyheaders):
from plugins.endpoints.models import EndpointCommand
assetid = _computer(db, 'BAY-01')
secret = _credential(client, keyheaders)
command = EndpointCommand(assetid=assetid, action='mtconnect_status',
status=EndpointCommand.STATUS_RUNNING)
db.session.add(command)
db.session.commit()
response = client.post(
f'/api/endpoints/commands/{command.endpointcommandid}/result',
json={'succeeded': True},
headers={'X-Endpoint-Credential': secret})
assert response.status_code == 200, response.get_data(as_text=True)
def _fleet_alert(db, alertscope=None):
from plugins.notifications.models import Notification, NotificationType
ntype = NotificationType(typename='Fleet Alert', typecolor='#d93f3f',
isactive=True)
db.session.add(ntype)
db.session.flush()
note = Notification(notificationtypeid=ntype.notificationtypeid,
notification='Line 3 is down', alertfleet=True,
alerttimeout=60, isactive=True, alertscope=alertscope)
db.session.add(note)
db.session.commit()
return note.notificationid
def test_alert_ack_refuses_another_asset(client, db, app, keyheaders):
_computer(db, 'BAY-01')
other = _computer(db, 'BAY-02', serialnumber='SER-2')
secret = _credential(client, keyheaders)
notificationid = _fleet_alert(db)
response = client.post(
f'/api/endpoints/alerts/{notificationid}/acknowledge',
json={'assetid': other},
headers={'X-Endpoint-Credential': secret})
assert response.status_code == 403
assert _code(response) == 'ASSET_MISMATCH'
byhostname = client.post(
f'/api/endpoints/alerts/{notificationid}/acknowledge',
json={'hostname': 'BAY-02'},
headers={'X-Endpoint-Credential': secret})
assert byhostname.status_code == 403
assert _code(byhostname) == 'ASSET_MISMATCH'
def test_alert_ack_with_no_assetid_means_the_caller(client, db, app,
keyheaders):
from plugins.notifications.models import NotificationAcknowledgement
assetid = _computer(db, 'BAY-01')
secret = _credential(client, keyheaders)
notificationid = _fleet_alert(db)
response = client.post(
f'/api/endpoints/alerts/{notificationid}/acknowledge',
json={'assetid': 0},
headers={'X-Endpoint-Credential': secret})
assert response.status_code == 201, response.get_data(as_text=True)
assert NotificationAcknowledgement.query.filter_by(
notificationid=notificationid, assetid=assetid).count() == 1
def test_alert_ack_refuses_an_alert_not_scoped_to_the_caller(
client, db, app, keyheaders):
from plugins.notifications.models import NotificationAcknowledgement
_computer(db, 'BAY-01')
_computer(db, 'BAY-02', serialnumber='SER-2')
_computer(db, 'BAY-03', serialnumber='SER-3')
secret = _credential(client, keyheaders)
notificationid = _fleet_alert(db, alertscope={'hostname': ['BAY-02',
'BAY-03']})
url = f'/api/endpoints/alerts/{notificationid}/acknowledge'
unknown = client.post('/api/endpoints/alerts/999999/acknowledge', json={},
headers={'X-Endpoint-Credential': secret})
bycredential = client.post(url, json={},
headers={'X-Endpoint-Credential': secret})
assert bycredential.status_code == 404
assert bycredential.get_json()['data']['error']['code'] == \
unknown.get_json()['data']['error']['code']
bysharedkey = client.post(url, json={'hostname': 'BAY-03'},
headers=keyheaders)
assert bysharedkey.status_code == 201, bysharedkey.get_data(as_text=True)
_computer(db, 'BAY-04', serialnumber='SER-4')
outside = client.post(url, json={'hostname': 'BAY-04'}, headers=keyheaders)
assert outside.status_code == 404
assert NotificationAcknowledgement.query.filter_by(
notificationid=notificationid).count() == 1
def test_collector_computers_refuses_another_hostname(client, db, app,
keyheaders):
_computer(db, 'BAY-01')
_computer(db, 'BAY-02', serialnumber='SER-2')
secret = _credential(client, keyheaders)
headers = {'X-Endpoint-Credential': secret}
mismatch = client.post('/api/collector/computers',
json={'hostname': 'BAY-02'}, headers=headers)
assert mismatch.status_code == 403, mismatch.get_data(as_text=True)
assert _code(mismatch) == 'ASSET_MISMATCH'
# A credential never creates a PC.
unknown = client.post('/api/collector/computers',
json={'hostname': 'NEW-BAY'}, headers=headers)
assert unknown.status_code == 403
assert _code(unknown) == 'ASSET_MISMATCH'
own = client.post('/api/collector/computers',
json={'hostname': 'BAY-01'}, headers=headers)
assert own.status_code == 200, own.get_data(as_text=True)
def test_collector_refuses_shared_key_for_an_enrolled_pc(client, db, app,
keyheaders):
_computer(db, 'BAY-01')
_credential(client, keyheaders)
response = client.post('/api/collector/computers',
json={'hostname': 'BAY-01'}, headers=keyheaders)
assert response.status_code == 401
assert _code(response) == 'SHARED_KEY_REFUSED'
def test_collector_invalid_credential(client, db, app):
response = client.post('/api/collector/printers',
json={'hostname': 'BAY-01', 'queues': []},
headers={'X-Endpoint-Credential': 'sdbe_' + 'a' * 43})
assert response.status_code == 401
assert _code(response) == 'CREDENTIAL_INVALID'
def test_endpoints_collect_route_is_the_collector(client, db, app, keyheaders):
from plugins.endpoints.models import EndpointState
assetid = _computer(db, 'BAY-01')
secret = _credential(client, keyheaders)
response = client.post('/api/endpoints/collect',
json={'hostname': 'BAY-01', 'agentversion': '1.20.0'},
headers={'X-Endpoint-Credential': secret})
assert response.status_code == 200, response.get_data(as_text=True)
assert EndpointState.query.filter_by(assetid=assetid).one() \
.agentversion == '1.20.0'
_computer(db, 'BAY-02', serialnumber='SER-2')
mismatch = client.post('/api/endpoints/collect',
json={'hostname': 'BAY-02'},
headers={'X-Endpoint-Credential': secret})
assert mismatch.status_code == 403
assert _code(mismatch) == 'ASSET_MISMATCH'
def test_contract_exports_answer_through_the_endpoints_plugin(client, db, app,
keyheaders):
from shopdb.api import agent_shared_key_refused, resolve_agent_credential
assetid = _computer(db, 'BAY-01')
assert agent_shared_key_refused('BAY-01') is False
secret = _credential(client, keyheaders)
with app.test_request_context():
assert resolve_agent_credential(secret) == assetid
assert resolve_agent_credential('sdbe_' + 'z' * 43) is None
assert agent_shared_key_refused('BAY-01') is True
assert agent_shared_key_refused('UNKNOWN') is False
# -- admin gating ------------------------------------------------------------
@pytest.mark.parametrize('method,url', [
('get', '/api/endpoints/enrollment'),
('get', '/api/endpoints/enrollment/migration'),
('post', '/api/endpoints/enrollment/requests/1/approve'),
('post', '/api/endpoints/enrollment/requests/1/reject'),
('post', '/api/endpoints/enrollment/assets/1/revoke'),
('post', '/api/endpoints/enrollment/assets/1/allow-reenroll'),
('get', '/api/endpoints/settings/server-cert/download'),
('put', '/api/endpoints/enrollment/settings'),
('post', '/api/endpoints/enrollment/migration/wave'),
])
def test_member_without_a_role_is_forbidden(client, db, member_headers, method,
url):
response = getattr(client, method)(url, json={}, headers=member_headers)
assert response.status_code == 403
def test_enrollment_listing_and_settings(client, db, app, keyheaders,
auth_headers):
enrolled = _computer(db, 'BAY-01')
_computer(db, 'BAY-02', serialnumber='SER-2')
_computer(db, 'BAY-03', serialnumber=None)
_credential(client, keyheaders)
_enroll(client, keyheaders, hostname='BAY-03', serialnumber='X')
response = client.get('/api/endpoints/enrollment', headers=auth_headers)
assert response.status_code == 200, response.get_data(as_text=True)
body = response.get_json()
data = body['data']
assert data['settings'] == {'sharedkeyallowed': True, 'autoenroll': True,
'revokeddeniessharedkey': False,
'maxpendingenrollments': 500}
assert data['counts'] == {'enrolled': 1, 'notenrolled': 1,
'pendingcomputers': 1, 'pending': 1}
states = {row['hostname']: row['state'] for row in data['computers']}
assert states == {'BAY-01': 'enrolled', 'BAY-02': 'notenrolled',
'BAY-03': 'pending'}
row = next(r for r in data['computers'] if r['assetid'] == enrolled)
assert row['credentialprefix'].startswith('sdbe_')
assert row['enrolledvia'] == 'auto'
assert [r['reason'] for r in data['requests']
if r['status'] == 'pending'] == ['no serial on record']
assert body['meta']['pagination']['total'] == 3
filtered = client.get('/api/endpoints/enrollment?state=enrolled',
headers=auth_headers).get_json()['data']
assert [r['hostname'] for r in filtered['computers']] == ['BAY-01']
put = client.put('/api/endpoints/enrollment/settings',
json={'sharedkeyallowed': False}, headers=auth_headers)
assert put.status_code == 200
assert put.get_json()['data'] == {'sharedkeyallowed': False,
'autoenroll': True,
'revokeddeniessharedkey': False,
'maxpendingenrollments': 500}
bad = client.put('/api/endpoints/enrollment/settings',
json={'autoenroll': 'yes'}, headers=auth_headers)
assert bad.status_code == 400
def test_enrollment_pending_count_includes_unmatched_requests(
client, db, app, keyheaders, auth_headers):
_computer(db, 'BAY-01', serialnumber=None)
_computer(db, 'BAY-02', serialnumber='SER-2')
_enroll(client, keyheaders, hostname='BAY-01', serialnumber='X')
_enroll(client, {}, hostname='NOT-IN-SHOPDB', serialnumber='Y')
data = client.get('/api/endpoints/enrollment',
headers=auth_headers).get_json()['data']
pendingrequests = [r for r in data['requests'] if r['status'] == 'pending']
assert len(pendingrequests) == 2
assert data['counts'] == {'enrolled': 0, 'notenrolled': 1,
'pendingcomputers': 1, 'pending': 2}
states = [row['state'] for row in data['computers']]
assert states.count('pending') == data['counts']['pendingcomputers']
# -- approve binds the hostname ----------------------------------------------
#
# A one-off PC installed by the bootstrap installer is pending for `unknown
# hostname` by definition. Approving used to set matchedassetid only, so the
# hostname the agent sends resolved to nothing, bind_hostname compared that with
# the credential's asset and answered 403 ASSET_MISMATCH on every call - the PC
# enrolled and then never worked until someone typed the hostname in by hand.
def _forget_hostname(db, assetid):
from plugins.computers.models import Computer
Computer.query.filter_by(assetid=assetid).one().hostname = None
db.session.commit()
def _hostname(assetid):
from plugins.computers.models import Computer
return Computer.query.filter_by(assetid=assetid).one().hostname
def _approve(client, auth_headers, requestid, assetid, **body):
return client.post(
f'/api/endpoints/enrollment/requests/{requestid}/approve',
json=dict(assetid=assetid, **body), headers=auth_headers)
def _computer_audits():
return AuditLog.query.filter_by(entitytype='Computer').all()
def test_approve_records_the_hostname_on_a_computer_that_has_none(
client, db, app, auth_headers):
assetid = _computer(db, 'PLACEHOLDER', serialnumber='SER-9')
_forget_hostname(db, assetid)
pending = _enroll(client, {}, hostname='LOANER-07', serialnumber='SER-9')
assert pending.get_json()['data']['reason'] == 'unknown hostname'
requestid = pending.get_json()['data']['requestid']
approved = _approve(client, auth_headers, requestid, assetid)
assert approved.status_code == 200, approved.get_data(as_text=True)
assert approved.get_json()['data']['hostnameresult'] == 'set'
assert _hostname(assetid) == 'LOANER-07'
audit = _computer_audits()
assert len(audit) == 1
assert audit[0].changes['hostname'] == {'old': None, 'new': 'LOANER-07'}
# The whole point: the PC collects its credential and is then authorized.
secret = _credential(client, {}, hostname='LOANER-07',
serialnumber='SER-9')
works = client.get('/api/endpoints/alerts?hostname=LOANER-07',
headers={'X-Endpoint-Credential': secret})
assert works.status_code == 200, works.get_data(as_text=True)
def test_approve_refuses_to_rename_a_computer_without_being_told_to(
client, db, app, auth_headers):
from plugins.endpoints.models import EndpointEnrollRequest
assetid = _computer(db, 'BAY-09', serialnumber='SER-9')
pending = _enroll(client, {}, hostname='LOANER-07', serialnumber='SER-9')
requestid = pending.get_json()['data']['requestid']
refused = _approve(client, auth_headers, requestid, assetid)
assert refused.status_code == 409
error = refused.get_json()['data']['error']
assert 'BAY-09' in error['message'] and 'LOANER-07' in error['message']
assert error['details']['hostnameconflict'] is True
# Nothing moved: not the hostname, not the request.
assert _hostname(assetid) == 'BAY-09'
assert _computer_audits() == []
row = db.session.get(EndpointEnrollRequest, requestid)
assert row.status == EndpointEnrollRequest.STATUS_PENDING
assert row.matchedassetid is None
def test_approve_with_sethostname_overwrites_and_audits_the_rename(
client, db, app, auth_headers):
assetid = _computer(db, 'BAY-09', serialnumber='SER-9')
pending = _enroll(client, {}, hostname='LOANER-07', serialnumber='SER-9')
requestid = pending.get_json()['data']['requestid']
approved = _approve(client, auth_headers, requestid, assetid,
sethostname=True)
assert approved.status_code == 200, approved.get_data(as_text=True)
assert approved.get_json()['data']['hostnameresult'] == 'overwritten'
assert _hostname(assetid) == 'LOANER-07'
audit = _computer_audits()
assert len(audit) == 1
assert audit[0].changes['hostname'] == {'old': 'BAY-09',
'new': 'LOANER-07'}
assert audit[0].username == 'testadmin'
assert _approve(client, auth_headers, requestid, assetid,
sethostname='yes').status_code == 400
def test_approve_onto_a_matching_hostname_changes_nothing(
client, db, app, keyheaders, auth_headers):
assetid = _computer(db, 'BAY-01')
_setting(db, 'plugin.endpoints.autoenroll', False)
pending = _enroll(client, keyheaders)
requestid = pending.get_json()['data']['requestid']
approved = _approve(client, auth_headers, requestid, assetid)
assert approved.status_code == 200, approved.get_data(as_text=True)
assert approved.get_json()['data']['hostnameresult'] == 'unchanged'
assert _hostname(assetid) == 'BAY-01'
assert _computer_audits() == []
def test_a_hostname_on_no_computer_record_is_refused_but_named_as_such(
client, db, app, keyheaders):
"""Same code and status, an honest message: the credential is good and the
hostname is simply on no record, which is a server-side data problem, not a
PC claiming to be another one."""
assetid = _computer(db, 'BAY-01')
secret = _credential(client, keyheaders)
_forget_hostname(db, assetid)
response = client.get('/api/endpoints/alerts?hostname=BAY-01',
headers={'X-Endpoint-Credential': secret})
assert response.status_code == 403
assert _code(response) == 'ASSET_MISMATCH'
message = response.get_json()['data']['error']['message']
assert 'BAY-01' in message
assert 'no computer record' in message.lower()
def test_approve_refuses_an_enrolled_computer(client, db, app, keyheaders,
auth_headers):
assetid = _computer(db, 'BAY-01')
_credential(client, keyheaders)
pending = _enroll(client, keyheaders, nonce=OTHER_NONCE)
requestid = pending.get_json()['data']['requestid']
response = client.post(
f'/api/endpoints/enrollment/requests/{requestid}/approve',
json={'assetid': assetid}, headers=auth_headers)
assert response.status_code == 409
# -- server certificate pin --------------------------------------------------
def test_server_cert_reports_the_spki_pin(client, db, auth_headers, tmp_path,
monkeypatch):
import hashlib
from datetime import datetime, timedelta, timezone
from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.x509.oid import NameOID
from plugins.endpoints.api import routes as endpoint_routes
key = ec.generate_private_key(ec.SECP256R1())
name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, 'pin.example.test')])
now = datetime.now(timezone.utc)
certificate = (x509.CertificateBuilder()
.subject_name(name).issuer_name(name)
.public_key(key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(now - timedelta(days=1))
.not_valid_after(now + timedelta(days=30))
.sign(key, hashes.SHA256()))
certdir = tmp_path / 'certs'
certdir.mkdir()
(certdir / 'server.crt').write_bytes(
certificate.public_bytes(serialization.Encoding.PEM))
monkeypatch.chdir(tmp_path)
if hasattr(endpoint_routes, '_server_cert_dir'):
monkeypatch.setattr(endpoint_routes, '_server_cert_dir', lambda: certdir)
expected = hashlib.sha256(key.public_key().public_bytes(
serialization.Encoding.DER,
serialization.PublicFormat.SubjectPublicKeyInfo)).hexdigest()
response = client.get('/api/endpoints/settings/server-cert',
headers=auth_headers)
assert response.status_code == 200, response.get_data(as_text=True)
data = response.get_json()['data']
assert data['spkisha256'] == expected
sha1 = certificate.fingerprint(hashes.SHA1()).hex().upper()
assert data['sha1thumbprint'] == sha1
assert ':' not in data['sha1thumbprint']
download = client.get('/api/endpoints/settings/server-cert/download',
headers=auth_headers)
assert download.status_code == 200
assert download.mimetype == 'application/pkix-cert'
assert 'attachment' in download.headers['Content-Disposition']
assert '.cer' in download.headers['Content-Disposition']
assert download.data == certificate.public_bytes(serialization.Encoding.DER)
assert b'PRIVATE' not in download.data

View File

@@ -0,0 +1,140 @@
"""Migration 0023: per-PC credential tables, and the client certificate cleanup.
Two starting points matter. A database that ran the 2026-09-14 builds of
0020-0022 carries app_fingerprint and five certificate columns on assets; a
fresh one carries neither. Both must reach the same schema, twice over.
"""
import importlib.util
from pathlib import Path
from alembic.migration import MigrationContext
from alembic.operations import Operations
from sqlalchemy import create_engine, inspect, text
_VERSIONS = (Path(__file__).resolve().parent.parent.parent
/ 'plugins' / 'endpoints' / 'migrations' / 'versions')
_CHAIN = ('0020_app_delta_fingerprints.py', '0021_client_certificates.py',
'0022_client_cert_revocation.py', '0023_endpoint_credentials.py')
_CERT_COLUMNS = {'client_cert_fingerprint', 'cert_issued_at', 'cert_expires_at',
'cert_revoked_at', 'cert_revocation_reason'}
def _load(filename):
path = _VERSIONS / filename
spec = importlib.util.spec_from_file_location('endpoints_mig_' + path.stem,
str(path))
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def _run(connection, step='upgrade', files=_CHAIN):
operations = Operations(MigrationContext.configure(connection))
operations._install_proxy()
try:
for filename in files:
getattr(_load(filename), step)()
finally:
operations._remove_proxy()
connection.commit()
def _base_schema(connection, legacy):
certcolumns = ''
if legacy:
certcolumns = (', client_cert_fingerprint VARCHAR(64), '
'cert_issued_at DATETIME, cert_expires_at DATETIME, '
'cert_revoked_at DATETIME, '
'cert_revocation_reason VARCHAR(255)')
connection.execute(text(
'CREATE TABLE assets (assetid INTEGER PRIMARY KEY, '
'assetnumber VARCHAR(50)' + certcolumns + ')'))
fingerprint = ', app_fingerprint VARCHAR(64)' if legacy else ''
connection.execute(text(
'CREATE TABLE endpointapplications (endpointapplicationid INTEGER '
'PRIMARY KEY, assetid INTEGER, displayname VARCHAR(255)'
+ fingerprint + ')'))
if legacy:
connection.execute(text(
'CREATE INDEX idx_assets_cert_fingerprint ON assets '
'(client_cert_fingerprint)'))
connection.execute(text(
'CREATE UNIQUE INDEX idx_endpointapplications_fingerprint ON '
'endpointapplications (assetid, app_fingerprint)'))
connection.execute(text(
"INSERT INTO endpointapplications VALUES (1, 7, 'App', '"
+ 'a' * 64 + "')"))
connection.commit()
def _assert_head_schema(connection):
inspector = inspect(connection)
assetcolumns = {c['name'] for c in inspector.get_columns('assets')}
assert not assetcolumns & _CERT_COLUMNS
appcolumns = {c['name'] for c in inspector.get_columns('endpointapplications')}
assert 'appfingerprint' in appcolumns
assert 'app_fingerprint' not in appcolumns
indexed = {tuple(ix['column_names']): ix['unique'] for ix in
inspector.get_indexes('endpointapplications')}
assert indexed.get(('assetid', 'appfingerprint')) in (True, 1)
for table in ('endpointcredentials', 'endpointenrollrequests',
'endpointmigrationwaves'):
assert inspector.has_table(table), table
credentialcolumns = {c['name'] for c in inspector.get_columns('endpointcredentials')}
assert credentialcolumns == {
'credentialid', 'assetid', 'credentialhash', 'credentialprefix',
'enrolledvia', 'issuedat', 'lastusedat', 'revokedat', 'revokedby',
'reenrollblocked', 'reenrollblockedreason'}
requestindexes = [tuple(ix['column_names']) for ix in
inspector.get_indexes('endpointenrollrequests')]
assert requestindexes.count(('hostname', 'serialnumber')) == 1
def test_upgrade_from_the_client_certificate_builds():
engine = create_engine('sqlite://')
with engine.connect() as connection:
_base_schema(connection, legacy=True)
_run(connection)
_assert_head_schema(connection)
# Data survives the rename.
assert connection.execute(text(
'SELECT appfingerprint FROM endpointapplications')).scalar() == 'a' * 64
_run(connection)
_assert_head_schema(connection)
def test_upgrade_on_a_fresh_database():
engine = create_engine('sqlite://')
with engine.connect() as connection:
_base_schema(connection, legacy=False)
_run(connection)
_assert_head_schema(connection)
_run(connection)
_assert_head_schema(connection)
def test_retired_certificate_revisions_are_no_ops():
engine = create_engine('sqlite://')
with engine.connect() as connection:
_base_schema(connection, legacy=False)
_run(connection, files=_CHAIN[1:3])
columns = {c['name'] for c in inspect(connection).get_columns('assets')}
assert not columns & _CERT_COLUMNS
def test_downgrade_drops_the_credential_tables():
engine = create_engine('sqlite://')
with engine.connect() as connection:
_base_schema(connection, legacy=False)
_run(connection)
_run(connection, step='downgrade', files=_CHAIN[3:])
inspector = inspect(connection)
for table in ('endpointcredentials', 'endpointenrollrequests',
'endpointmigrationwaves'):
assert not inspector.has_table(table), table
_run(connection, files=_CHAIN[3:])
_assert_head_schema(connection)

View File

@@ -0,0 +1,168 @@
"""Pending Dell Command | Update reporting: ingest and status-card exposure.
The agent's shopdb-dellupdates utility reads the log the \\ShopDB-DellCommandScan
scheduled task writes and reports a short summary on the normal telemetry
check-in. These tests cover the ingest write path and the three-state rendering
on the status panel, which is the part most likely to be broken by a well-meaning
simplification: "never scanned" must NOT render as "No".
"""
import json
from shopdb.core.models import Asset, AssetType
STATUS_URL = '/api/endpoints/asset/{}/status'
def _computer(db, hostname):
"""Create a computer Asset + Computer row; return the asset."""
from plugins.computers.models import Computer, ComputerType
atype = AssetType.query.filter_by(assettype='computer').first()
if not atype:
atype = AssetType(assettype='computer')
db.session.add(atype)
db.session.flush()
ctype = ComputerType.query.filter_by(computertype='CMM PC').first()
if not ctype:
ctype = ComputerType(computertype='CMM PC')
db.session.add(ctype)
db.session.flush()
asset = Asset(assetnumber=hostname, assettypeid=atype.assettypeid,
isactive=True)
db.session.add(asset)
db.session.flush()
db.session.add(Computer(assetid=asset.assetid, hostname=hostname,
computertypeid=ctype.computertypeid))
db.session.flush()
return asset
def _checkin(db, hostname, payload):
"""Run a check-in for hostname with the extra payload keys merged in."""
from plugins.endpoints.plugin import EndpointsPlugin
body = {'hostname': hostname}
body.update(payload)
EndpointsPlugin().apply_collector_payload(body)
db.session.commit()
def _status_fields(client, asset, auth_headers):
response = client.get(STATUS_URL.format(asset.assetid), headers=auth_headers)
assert response.status_code == 200
return {f['label']: f['value']
for f in response.get_json()['data']['fields']}
def test_checkin_persists_the_dell_summary(client, db, app, auth_headers):
"""A reported dellupdates payload is stored on the endpoint state."""
from plugins.endpoints.models.endpoint import EndpointState
_computer(db, 'DELL01')
_checkin(db, 'DELL01', {'dellupdates': {
'status': 'ok', 'count': 1, 'truncated': False,
'updates': [{'name': 'Dell BIOS', 'severity': 'Urgent',
'version': '1.2.3'}],
}})
state = EndpointState.query.filter_by(
assetid=Asset.query.filter_by(assetnumber='DELL01').first().assetid
).first()
stored = json.loads(state.dellupdatesjson)
assert stored['count'] == 1
assert stored['updates'][0]['name'] == 'Dell BIOS'
def test_pending_updates_show_yes_with_a_count(client, db, app, auth_headers):
"""A scan with pending items renders Yes plus how many."""
asset = _computer(db, 'DELL02')
_checkin(db, 'DELL02', {'dellupdates': {
'status': 'ok', 'count': 3, 'truncated': False, 'updates': [],
}})
fields = _status_fields(client, asset, auth_headers)
assert fields['Pending Dell updates'] == 'Yes (3)'
def test_a_clean_scan_shows_no(client, db, app, auth_headers):
"""A scan that found nothing renders No, not a hidden row."""
asset = _computer(db, 'DELL03')
_checkin(db, 'DELL03', {'dellupdates': {
'status': 'ok', 'count': 0, 'truncated': False, 'updates': [],
}})
fields = _status_fields(client, asset, auth_headers)
assert fields['Pending Dell updates'] == 'No'
def test_a_never_scanned_endpoint_says_so(client, db, app, auth_headers):
"""Never scanned renders as its own visible state, not as No and not hidden.
Two failure modes are being guarded at once: showing 'No' would claim the
machine is current when nobody ever checked, and hiding the row entirely
reads to an operator as a broken panel.
"""
asset = _computer(db, 'DELL04')
_checkin(db, 'DELL04', {})
fields = _status_fields(client, asset, auth_headers)
assert fields['Pending Dell updates'] == 'Never scanned'
def test_an_unknown_status_says_never_scanned(client, db, app, auth_headers):
"""The utility's own 'never scanned' answer renders as never scanned."""
asset = _computer(db, 'DELL05')
_checkin(db, 'DELL05', {'dellupdates': {
'status': 'unknown', 'count': 0, 'truncated': False, 'updates': [],
}})
fields = _status_fields(client, asset, auth_headers)
assert fields['Pending Dell updates'] == 'Never scanned'
def test_a_failed_scan_read_is_distinct_from_never_scanned(client, db, app,
auth_headers):
"""An unreadable scan log is its own state, not silence and not clean."""
asset = _computer(db, 'DELL06')
_checkin(db, 'DELL06', {'dellupdates': {
'status': 'error', 'count': 0, 'truncated': False, 'updates': [],
}})
fields = _status_fields(client, asset, auth_headers)
assert fields['Pending Dell updates'] == 'Unknown (scan unreadable)'
def test_a_malformed_blob_does_not_break_the_panel(client, db, app, auth_headers):
"""A corrupt cached payload degrades to the unknown state, not a 500."""
from plugins.endpoints.models.endpoint import EndpointState
asset = _computer(db, 'DELL07')
_checkin(db, 'DELL07', {})
state = EndpointState.query.filter_by(assetid=asset.assetid).first()
state.dellupdatesjson = 'not json at all'
db.session.commit()
fields = _status_fields(client, asset, auth_headers)
assert fields['Pending Dell updates'] == 'Never scanned'
def test_an_omitted_key_preserves_the_last_result(client, db, app, auth_headers):
"""A check-in without the key does not erase a previously reported scan.
The agent omits dellupdates entirely when the collector utility is absent,
so overwriting to null would discard a still-valid result.
"""
asset = _computer(db, 'DELL08')
_checkin(db, 'DELL08', {'dellupdates': {
'status': 'ok', 'count': 2, 'truncated': False, 'updates': [],
}})
_checkin(db, 'DELL08', {})
fields = _status_fields(client, asset, auth_headers)
assert fields['Pending Dell updates'] == 'Yes (2)'

View File

@@ -0,0 +1,108 @@
"""Disk health rows: a dying drive must never render as a healthy one.
The agent reports drive health from two sources because neither covers the
fleet. MSFT_PhysicalDisk is the only one that reports NVMe - verified on a
two-NVMe box where the classic ATA SMART class returned zero instances while
both drives were present and healthy. Win32_DiskDrive covers ATA/SATA and hosts
where the Storage namespace is missing.
That means the panel can receive the same physical drive twice, from sources
that may disagree, plus rows where health could not be read at all. Every test
here pins a way this card could start showing a failing or unreadable drive as
fine, which is the only failure mode that matters: nobody investigates a disk
that looks healthy.
"""
from plugins.endpoints.api.routes import _diskhealth_rows
def _drive(deviceid, predictfailure, healthstatus='Healthy',
source='physicaldisk', model='Samsung SSD'):
return {
'deviceid': deviceid,
'model': model,
'mediatype': 'SSD',
'bustype': 'NVMe',
'healthstatus': healthstatus,
'predictfailure': predictfailure,
'source': source,
}
def test_a_failing_drive_sorts_above_healthy_ones():
"""On a multi-disk box the bad drive must not be below the fold."""
rows = _diskhealth_rows([
_drive('0', False),
_drive('1', True, healthstatus='Unhealthy'),
_drive('2', False),
])
assert [row['deviceid'] for row in rows] == ['1', '0', '2']
assert rows[0]['verdict'] == 'Failure predicted'
def test_a_drive_that_did_not_answer_reads_as_unknown_not_ok():
"""Null predictfailure is an unread drive, not a healthy one."""
rows = _diskhealth_rows([_drive('0', None, healthstatus='')])
assert rows[0]['verdict'] == 'Unknown'
assert rows[0]['healthstatus'] == 'Unknown'
def test_unknown_outranks_ok_so_silence_is_not_buried_under_health():
"""A drive that stopped answering is a weaker claim than one saying OK."""
rows = _diskhealth_rows([
_drive('0', False),
_drive('1', None),
])
assert [row['verdict'] for row in rows] == ['Unknown', 'OK']
def test_both_sources_survive_for_the_same_hardware():
"""Two disagreeing views of one drive is the signal, not noise.
Collapsing them would hide the case worth looking at: one source calling a
drive healthy while the other predicts failure.
"""
rows = _diskhealth_rows([
_drive('0', False, source='physicaldisk'),
_drive('0', True, healthstatus='Pred Fail', source='diskdrive'),
])
assert len(rows) == 2
assert {row['source'] for row in rows} == {'physicaldisk', 'diskdrive'}
assert rows[0]['verdict'] == 'Failure predicted'
def test_input_order_does_not_change_which_drive_is_shown_first():
"""The verdict ranking must decide the order, not arrival order."""
healthy = _drive('0', False)
failing = _drive('1', True)
forward = _diskhealth_rows([healthy, failing])
backward = _diskhealth_rows([failing, healthy])
assert forward == backward
assert forward[0]['deviceid'] == '1'
def test_an_nvme_drive_is_reported_rather_than_dropped():
"""The whole reason for the second source: ATA SMART returns nothing here."""
rows = _diskhealth_rows([_drive('0', False, source='physicaldisk')])
assert rows[0]['bustype'] == 'NVMe'
assert rows[0]['verdict'] == 'OK'
def test_a_malformed_payload_does_not_take_the_panel_down():
rows = _diskhealth_rows([_drive('0', False), 'not a dict', None])
assert len(rows) == 1
assert rows[0]['deviceid'] == '0'
def test_a_missing_or_wrong_shaped_payload_is_an_empty_panel():
assert _diskhealth_rows(None) == []
assert _diskhealth_rows({}) == []
assert _diskhealth_rows([]) == []

View File

@@ -0,0 +1,220 @@
"""The two bounds on POST /api/endpoints/enroll.
The route takes no auth on purpose - a PC installed without the shared key must
still be able to reach an admin - and it writes a row for every new
(hostname, serialnumber) pair. That is an unauthenticated caller writing to the
database in a loop, and it is also a denial of the enrollment workflow: junk
pending rows bury the real requests an admin has to approve.
Two bounds, and they answer different questions. The per-IP fixed-window limiter
bounds how FAST; the settings-backed cap bounds how MANY rows exist. Neither may
touch a PC already in the workflow, so the cap refuses only a hostname and
serial never seen before and an existing pending or approved row still answers
exactly as it did.
"""
import pytest
from shopdb.core.models import Asset, AssetType, Setting
from plugins.endpoints.api.enrollment import PENDING_CAP_SETTING
from plugins.endpoints.models import EndpointEnrollRequest
ENROLL_URL = '/api/endpoints/enroll'
NONCE = 'n' * 40
@pytest.fixture
def limits(app):
"""Both bounds on, at their shipped defaults, restored afterwards.
The limiter counts in the cache extension, which is process-wide and
outlives a test. Any other test in the same worker that posts to /enroll
spends budget in the same window and IP bucket, so a count-exactly test has
to start from a clean counter - and leave one behind, since it deliberately
fills a bucket. Which tests share a worker depends on the xdist
distribution, so without this the failure is intermittent and looks
unrelated to whatever changed.
"""
from shopdb.api import cache
previous = {key: app.config.get(key) for key in (
'ENDPOINTS_ENROLL_RATELIMIT_ENABLED',
'ENDPOINTS_ENROLL_RATELIMIT_MAX',
'ENDPOINTS_ENROLL_RATELIMIT_WINDOW_SECONDS')}
app.config['ENDPOINTS_ENROLL_RATELIMIT_ENABLED'] = True
with app.app_context():
cache.clear()
yield app
with app.app_context():
cache.clear()
for key, value in previous.items():
if value is None:
app.config.pop(key, None)
else:
app.config[key] = value
def _computer(db, hostname, serialnumber):
from plugins.computers.models import Computer
atype = AssetType.query.filter_by(assettype='computer').first()
if atype is None:
atype = AssetType(assettype='computer')
db.session.add(atype)
db.session.flush()
asset = Asset(assetnumber=hostname, assettypeid=atype.assettypeid,
serialnumber=serialnumber, isactive=True)
db.session.add(asset)
db.session.flush()
db.session.add(Computer(assetid=asset.assetid, hostname=hostname))
db.session.commit()
return asset.assetid
def _body(hostname, serialnumber=None, nonce=NONCE):
return {'hostname': hostname,
'serialnumber': serialnumber or (hostname + '-SER'),
'enrollnonce': nonce, 'agentversion': '1.20.0'}
def _cap(db, value):
Setting.set(PENDING_CAP_SETTING, value, valuetype='integer',
category='plugin.endpoints')
db.session.commit()
def _pending(db, hostname, serialnumber=None, nonce=NONCE,
status=EndpointEnrollRequest.STATUS_PENDING):
from datetime import datetime, timezone
now = datetime.now(timezone.utc).replace(tzinfo=None)
row = EndpointEnrollRequest(
hostname=hostname, serialnumber=serialnumber or (hostname + '-SER'),
enrollnonce=nonce, status=status, requestedat=now)
db.session.add(row)
db.session.commit()
return row
def _code(response):
return response.get_json()['data']['error']['code']
# -- rate limit --------------------------------------------------------------
def test_the_limit_triggers_and_answers_the_standard_shape(client, db, limits):
"""Over budget is a 429 RATE_LIMITED, the same shape as every other
limiter in the product."""
limits.config['ENDPOINTS_ENROLL_RATELIMIT_MAX'] = 3
# An hour, so the fixed window cannot roll over mid-test and reset the
# count. The test is about the budget, not about the clock.
limits.config['ENDPOINTS_ENROLL_RATELIMIT_WINDOW_SECONDS'] = 3600
codes = []
for index in range(5):
response = client.post(ENROLL_URL, json=_body('BAY-%02d' % index))
codes.append(response.status_code)
assert codes[:3] == [202, 202, 202], codes
assert codes[3:] == [429, 429], codes
refused = client.post(ENROLL_URL, json=_body('BAY-99'))
assert refused.status_code == 429
assert _code(refused) == 'RATE_LIMITED'
def test_the_limit_is_off_when_the_config_says_so(client, db, limits):
limits.config['ENDPOINTS_ENROLL_RATELIMIT_ENABLED'] = False
limits.config['ENDPOINTS_ENROLL_RATELIMIT_MAX'] = 1
for index in range(4):
response = client.post(ENROLL_URL, json=_body('OFF-%02d' % index))
assert response.status_code == 202, response.get_data(as_text=True)
# -- pending cap -------------------------------------------------------------
def test_the_cap_refuses_a_new_row(client, db, limits):
"""At the cap, a hostname and serial nobody has seen is told to try later,
and no row is written for it."""
limits.config['ENDPOINTS_ENROLL_RATELIMIT_ENABLED'] = False
_cap(db, 2)
_pending(db, 'OLD-01')
_pending(db, 'OLD-02')
response = client.post(ENROLL_URL, json=_body('NEW-01'))
assert response.status_code == 503, response.get_data(as_text=True)
assert _code(response) == 'ENROLL_PENDING_FULL'
assert EndpointEnrollRequest.query.filter_by(hostname='NEW-01').count() == 0
assert EndpointEnrollRequest.query.count() == 2
def test_the_cap_still_serves_an_existing_pending_row(client, db, limits):
"""A PC already in the queue keeps asking and keeps being answered - the cap
must not lock the fleet out of the workflow it is protecting."""
limits.config['ENDPOINTS_ENROLL_RATELIMIT_ENABLED'] = False
_cap(db, 1)
row = _pending(db, 'OLD-01')
response = client.post(ENROLL_URL, json=_body('OLD-01'))
assert response.status_code == 202, response.get_data(as_text=True)
assert response.get_json()['data']['status'] == 'pending'
db.session.refresh(row)
assert row.lastseenat is not None
def test_the_cap_still_issues_to_an_approved_row(client, db, limits):
"""An approved request hands out its credential at the cap, because that
row is the admin's decision and refusing it would strand the PC."""
limits.config['ENDPOINTS_ENROLL_RATELIMIT_ENABLED'] = False
assetid = _computer(db, 'BAY-07', 'BAY-07-SER')
_cap(db, 1)
_pending(db, 'FILLER-01')
approved = _pending(db, 'BAY-07',
status=EndpointEnrollRequest.STATUS_APPROVED)
approved.matchedassetid = assetid
db.session.commit()
response = client.post(ENROLL_URL, json=_body('BAY-07'))
assert response.status_code == 201, response.get_data(as_text=True)
assert response.get_json()['data']['credential'].startswith('sdbe_')
def test_the_cap_setting_is_honoured(client, db, limits):
"""Raise the setting and the same request that was refused goes through;
0 removes the cap entirely."""
limits.config['ENDPOINTS_ENROLL_RATELIMIT_ENABLED'] = False
_cap(db, 1)
_pending(db, 'OLD-01')
refused = client.post(ENROLL_URL, json=_body('NEW-01'))
assert refused.status_code == 503
_cap(db, 5)
allowed = client.post(ENROLL_URL, json=_body('NEW-01'))
assert allowed.status_code == 202, allowed.get_data(as_text=True)
_cap(db, 0)
uncapped = client.post(ENROLL_URL, json=_body('NEW-02'))
assert uncapped.status_code == 202, uncapped.get_data(as_text=True)
def test_the_cap_is_editable_through_the_enrollment_settings_route(
client, db, auth_headers):
"""A setting nobody can change is a constant with extra steps."""
response = client.put('/api/endpoints/enrollment/settings',
json={'maxpendingenrollments': 42},
headers=auth_headers)
assert response.status_code == 200, response.get_data(as_text=True)
assert response.get_json()['data']['maxpendingenrollments'] == 42
assert int(Setting.get(PENDING_CAP_SETTING)) == 42
bad = client.put('/api/endpoints/enrollment/settings',
json={'maxpendingenrollments': -1}, headers=auth_headers)
assert bad.status_code == 400

View File

@@ -0,0 +1,352 @@
"""What a denial means at the enrollment door, and how long it lasts.
Three denials meet here, and each one used to be undone by the PC on the other
side of it:
reject An admin refuses a request. The agent then sends a new nonce and
the row was re-armed as a fresh cycle - which, with the shared key
and auto-enroll on, auto-issued the credential the admin had just
refused. A rejection binds to the identity (hostname + serial), so
it survives any nonce; only an admin approving it clears it.
cap The pending queue is bounded, and at the cap a PC nobody has seen
before is refused. Abandoned rows come out first, so a queue that
filled with junk drains itself instead of blocking every new PC.
revoke A revoked credential falls back to the shared key, which is how
the un-migrated fleet and the rollback to an older agent keep
reporting. plugin.endpoints.revokeddeniessharedkey (off by
default) turns a revoke into an eviction instead.
"""
from datetime import timedelta
import pytest
from shopdb.core.models import Asset, AssetType, Setting
from plugins.endpoints.api import agentauth
from plugins.endpoints.api.enrollment import (
PENDING_CAP_SETTING, PENDING_STALE_AFTER,
)
from plugins.endpoints.models import EndpointCredential, EndpointEnrollRequest
COLLECTOR_KEY = 'testcollectorkey'
ENROLL_URL = '/api/endpoints/enroll'
ALERTS_URL = '/api/endpoints/alerts'
NONCE = 'n' * 40
OTHER_NONCE = 'm' * 40
@pytest.fixture
def keyheaders(app):
app.config['COLLECTOR_API_KEY'] = COLLECTOR_KEY
return {'X-API-Key': COLLECTOR_KEY}
@pytest.fixture(autouse=True)
def nolimiter(app):
"""The per-IP limiter counts in a process-wide cache that outlives a test.
Nothing here is about how FAST a PC asks, and another test in the same
worker may already have spent the budget for this window and address, so the
limiter is off for this module and put back as it was.
"""
key = 'ENDPOINTS_ENROLL_RATELIMIT_ENABLED'
previous = app.config.get(key)
app.config[key] = False
yield
if previous is None:
app.config.pop(key, None)
else:
app.config[key] = previous
def _computer(db, hostname, serialnumber='SER-1'):
from plugins.computers.models import Computer
atype = AssetType.query.filter_by(assettype='computer').first()
if atype is None:
atype = AssetType(assettype='computer')
db.session.add(atype)
db.session.flush()
asset = Asset(assetnumber=hostname, assettypeid=atype.assettypeid,
serialnumber=serialnumber, isactive=True)
db.session.add(asset)
db.session.flush()
db.session.add(Computer(assetid=asset.assetid, hostname=hostname))
db.session.commit()
return asset.assetid
def _body(hostname='BAY-01', serialnumber='SER-1', nonce=NONCE):
return {'hostname': hostname, 'serialnumber': serialnumber,
'enrollnonce': nonce, 'agentversion': '1.20.0'}
def _enroll(client, headers, **kwargs):
return client.post(ENROLL_URL, json=_body(**kwargs), headers=headers)
def _pending_request(client, db, keyheaders, **kwargs):
"""A request waiting for an admin, with auto-enroll back on afterwards.
Auto-enroll off is what puts a clean match in the queue instead of issuing
at once; turning it back on is what made the old code hand the retry a
credential, so every rejection test needs both halves.
"""
_setting(db, 'plugin.endpoints.autoenroll', False)
response = _enroll(client, keyheaders, **kwargs)
assert response.status_code == 202, response.get_data(as_text=True)
_setting(db, 'plugin.endpoints.autoenroll', True)
return response.get_json()['data']['requestid']
def _reject(client, auth_headers, requestid):
return client.post(
f'/api/endpoints/enrollment/requests/{requestid}/reject',
headers=auth_headers)
def _setting(db, key, value, valuetype='boolean'):
Setting.set(key, value, valuetype=valuetype, category='plugin.endpoints')
db.session.commit()
def _code(response):
return response.get_json()['data']['error']['code']
# -- a rejection binds to the identity ---------------------------------------
def test_a_new_nonce_does_not_lift_a_rejection(client, db, app, keyheaders,
auth_headers):
"""The bug: reinstall the agent and the retry was auto-issued the
credential an admin had just refused."""
_computer(db, 'BAY-01')
requestid = _pending_request(client, db, keyheaders)
assert _reject(client, auth_headers, requestid).status_code == 200
retry = _enroll(client, keyheaders, nonce=OTHER_NONCE)
assert retry.status_code == 202, retry.get_data(as_text=True)
assert retry.get_json()['data']['reason'] == 'rejected'
assert retry.get_json()['data']['requestid'] == requestid
assert EndpointCredential.query.count() == 0
row = db.session.get(EndpointEnrollRequest, requestid)
assert row.status == EndpointEnrollRequest.STATUS_REJECTED
assert row.decidedat is not None
# Still one row for this identity: the retry did not queue a second.
assert EndpointEnrollRequest.query.count() == 1
def test_a_rejection_survives_however_often_the_pc_asks(client, db, app,
keyheaders,
auth_headers):
_computer(db, 'BAY-01')
requestid = _pending_request(client, db, keyheaders)
_reject(client, auth_headers, requestid)
for index in range(5):
again = _enroll(client, keyheaders, nonce=('x%d' % index) * 20)
assert again.status_code == 202
assert again.get_json()['data']['reason'] == 'rejected'
assert EndpointCredential.query.count() == 0
assert EndpointEnrollRequest.query.count() == 1
def test_a_rejection_does_not_touch_a_different_pc(client, db, app,
keyheaders, auth_headers):
"""Identity is hostname AND serial, so one PC's rejection is its own."""
_computer(db, 'BAY-01', serialnumber='SER-1')
other = _computer(db, 'BAY-02', serialnumber='SER-2')
requestid = _pending_request(client, db, keyheaders)
_reject(client, auth_headers, requestid)
clean = _enroll(client, keyheaders, hostname='BAY-02',
serialnumber='SER-2')
assert clean.status_code == 201, clean.get_data(as_text=True)
assert EndpointCredential.query.filter_by(assetid=other).one() \
.revokedat is None
def test_an_admin_clears_a_rejection_by_approving_it(client, db, app,
keyheaders, auth_headers):
"""The way back is the admin's, and it works for the reinstalled agent that
now carries a different nonce."""
assetid = _computer(db, 'BAY-01')
requestid = _pending_request(client, db, keyheaders)
_reject(client, auth_headers, requestid)
_enroll(client, keyheaders, nonce=OTHER_NONCE)
approve = client.post(
f'/api/endpoints/enrollment/requests/{requestid}/approve',
json={'assetid': assetid}, headers=auth_headers)
assert approve.status_code == 200, approve.get_data(as_text=True)
issued = _enroll(client, keyheaders, nonce=OTHER_NONCE)
assert issued.status_code == 201, issued.get_data(as_text=True)
assert EndpointCredential.query.filter_by(assetid=assetid).one() \
.enrolledvia == 'approved'
def test_an_approval_out_of_a_rejection_still_binds_to_one_installation(
client, db, app, keyheaders, auth_headers):
"""It binds to the installation the PC was last seen with, so a third
machine claiming the same hostname and serial still cannot collect it."""
assetid = _computer(db, 'BAY-01')
requestid = _pending_request(client, db, keyheaders)
_reject(client, auth_headers, requestid)
_enroll(client, keyheaders, nonce=OTHER_NONCE)
client.post(f'/api/endpoints/enrollment/requests/{requestid}/approve',
json={'assetid': assetid}, headers=auth_headers)
impostor = _enroll(client, keyheaders, nonce='z' * 40)
assert impostor.status_code == 403
assert _code(impostor) == 'ENROLL_NONCE_MISMATCH'
assert EndpointCredential.query.count() == 0
issued = _enroll(client, keyheaders, nonce=OTHER_NONCE)
assert issued.status_code == 201, issued.get_data(as_text=True)
# -- the pending cap ages out abandoned rows ---------------------------------
def _stale(db, hostname, days):
from datetime import datetime, timezone
seen = (datetime.now(timezone.utc).replace(tzinfo=None)
- timedelta(days=days))
row = EndpointEnrollRequest(
hostname=hostname, serialnumber=hostname + '-SER',
enrollnonce=NONCE, status=EndpointEnrollRequest.STATUS_PENDING,
requestedat=seen, lastseenat=seen)
db.session.add(row)
db.session.commit()
return row
def test_the_cap_ages_out_abandoned_rows_before_refusing(client, db, app):
"""A queue full of PCs that stopped asking must not block the one that is."""
_setting(db, PENDING_CAP_SETTING, 2, valuetype='integer')
_stale(db, 'GONE-01', PENDING_STALE_AFTER.days + 1)
_stale(db, 'GONE-02', PENDING_STALE_AFTER.days + 3)
response = client.post(ENROLL_URL, json=_body(hostname='NEW-01',
serialnumber='NEW-SER'))
assert response.status_code == 202, response.get_data(as_text=True)
hostnames = {row.hostname for row in EndpointEnrollRequest.query.all()}
assert hostnames == {'NEW-01'}
def test_the_age_out_spares_rows_still_being_asked_for(client, db, app):
"""Only rows nothing has touched go, and a decided row never goes: the
rejection above has to outlive any queue pressure."""
_setting(db, PENDING_CAP_SETTING, 2, valuetype='integer')
fresh = _stale(db, 'LIVE-01', 0)
stillasking = _stale(db, 'LIVE-02', 0)
decided = _stale(db, 'GONE-01', PENDING_STALE_AFTER.days + 1)
decided.status = EndpointEnrollRequest.STATUS_REJECTED
db.session.commit()
refused = client.post(ENROLL_URL, json=_body(hostname='NEW-01',
serialnumber='NEW-SER'))
assert refused.status_code == 503
assert _code(refused) == 'ENROLL_PENDING_FULL'
for row in (fresh, stillasking, decided):
assert db.session.get(EndpointEnrollRequest, row.requestid) is not None
# -- revocation and the shared key -------------------------------------------
def test_a_revoked_pc_keeps_the_shared_key_by_default(client, db, app,
keyheaders,
auth_headers):
"""Today's behaviour, and the setting default: a revoke is a rotation, not
an eviction, so the PC keeps reporting while it waits to enroll again."""
assetid = _computer(db, 'BAY-01')
assert _enroll(client, keyheaders).status_code == 201
client.post(f'/api/endpoints/enrollment/assets/{assetid}/revoke',
headers=auth_headers)
assert client.get(ALERTS_URL + '?hostname=BAY-01',
headers=keyheaders).status_code == 200
def test_revoked_denies_shared_key_evicts_the_pc(client, db, app, keyheaders,
auth_headers):
assetid = _computer(db, 'BAY-01')
assert _enroll(client, keyheaders).status_code == 201
client.post(f'/api/endpoints/enrollment/assets/{assetid}/revoke',
json={'blockreenroll': True, 'reason': 'stolen'},
headers=auth_headers)
_setting(db, agentauth.REVOKED_DENIES_SHARED_KEY_SETTING, True)
refused = client.get(ALERTS_URL + '?hostname=BAY-01', headers=keyheaders)
assert refused.status_code == 401
assert _code(refused) == 'SHARED_KEY_REFUSED'
assert 'revoked' in refused.get_json()['data']['error']['message']
def test_a_pc_that_never_enrolled_is_never_evicted(client, db, app,
keyheaders, auth_headers):
"""The un-migrated fleet has no credential row at all, so the setting can be
turned on without silencing it."""
_computer(db, 'BAY-01', serialnumber='SER-1')
_computer(db, 'BAY-02', serialnumber='SER-2')
assetid = _computer(db, 'BAY-03', serialnumber='SER-3')
_enroll(client, keyheaders, hostname='BAY-03', serialnumber='SER-3')
client.post(f'/api/endpoints/enrollment/assets/{assetid}/revoke',
headers=auth_headers)
_setting(db, agentauth.REVOKED_DENIES_SHARED_KEY_SETTING, True)
assert client.get(ALERTS_URL + '?hostname=BAY-01',
headers=keyheaders).status_code == 200
assert client.get(ALERTS_URL + '?hostname=BAY-02',
headers=keyheaders).status_code == 200
assert client.get(ALERTS_URL + '?hostname=BAY-03',
headers=keyheaders).status_code == 401
def test_the_collector_contract_follows_the_same_rule(client, db, app,
keyheaders,
auth_headers):
"""The generic collector path asks the plugin, not bind_hostname, so it has
to answer the same way."""
from plugins.endpoints.plugin import EndpointsPlugin
assetid = _computer(db, 'BAY-01')
assert _enroll(client, keyheaders).status_code == 201
client.post(f'/api/endpoints/enrollment/assets/{assetid}/revoke',
headers=auth_headers)
refused = EndpointsPlugin().agent_shared_key_refused
with app.test_request_context():
assert refused('BAY-01') is False
_setting(db, agentauth.REVOKED_DENIES_SHARED_KEY_SETTING, True)
assert refused('BAY-01') is True
assert refused('UNKNOWN') is False
def test_the_eviction_switch_is_editable_and_defaults_to_today(client, db,
auth_headers):
"""A setting nobody can change is a constant with extra steps."""
read = client.get('/api/endpoints/enrollment',
headers=auth_headers).get_json()['data']
assert read['settings']['revokeddeniessharedkey'] is False
put = client.put('/api/endpoints/enrollment/settings',
json={'revokeddeniessharedkey': True},
headers=auth_headers)
assert put.status_code == 200, put.get_data(as_text=True)
assert put.get_json()['data']['revokeddeniessharedkey'] is True
assert Setting.get(agentauth.REVOKED_DENIES_SHARED_KEY_SETTING) is True
bad = client.put('/api/endpoints/enrollment/settings',
json={'revokeddeniessharedkey': 'yes'},
headers=auth_headers)
assert bad.status_code == 400

View File

@@ -0,0 +1,485 @@
"""Moving the existing fleet onto per-PC credentials.
A PC on agent 1.19 or older holds only the shared key. It moves by receiving
agent 1.20, which enrolls itself. The migration surface computes, per computer,
whether that upgrade can be pushed, and pushes it in waves through the same
enqueue path as the single-asset agentupgrade route, packaging once per wave.
The agent store is isolated per test, as in test_endpoints_agent_upgrade_route.
"""
from datetime import datetime, timedelta, timezone
import pytest
from plugins.endpoints.api import agentregistry, agentstore, agentupgradescript
MIGRATION_URL = '/api/endpoints/enrollment/migration'
WAVE_URL = '/api/endpoints/enrollment/migration/wave'
_PE_BYTES = b'MZ' + b'\x00' * 4096
@pytest.fixture(autouse=True)
def isolate_store(tmp_path, monkeypatch):
siteagent = tmp_path / 'siteagent'
packages = tmp_path / 'applicationpackages'
scripts = tmp_path / 'scripts'
for directory in (siteagent, packages, scripts):
directory.mkdir()
monkeypatch.setattr(agentstore, '_siteagent_dir', lambda: str(siteagent))
monkeypatch.setattr(agentregistry, 'application_package_location',
lambda: (str(packages), '/api/applications/package/'))
monkeypatch.setattr(agentupgradescript, 'script_body_location',
lambda: (str(scripts), '/api/scripts/body/'))
return tmp_path
def _now():
return datetime.now(timezone.utc).replace(tzinfo=None)
def _publish_agent(version='1.20.0'):
agentstore.store_version('agent', version, _PE_BYTES, make_current=True)
def _pc(db, hostname, agentversion='1.19.0', serialnumber='SER',
reportedago=timedelta(hours=1), reports=True):
from shopdb.api import Asset, AssetType
from plugins.computers.models import Computer
from plugins.endpoints.models import EndpointState
atype = AssetType.query.filter_by(assettype='computer').first()
if atype is None:
atype = AssetType(assettype='computer')
db.session.add(atype)
db.session.flush()
asset = Asset(assetnumber=hostname + '-asset', name=hostname,
assettypeid=atype.assettypeid, isactive=True,
serialnumber=serialnumber)
db.session.add(asset)
db.session.flush()
db.session.add(Computer(assetid=asset.assetid, hostname=hostname))
if reports:
db.session.add(EndpointState(
assetid=asset.assetid, hostname=hostname, agentversion=agentversion,
lastreportedat=_now() - reportedago if reportedago else None))
db.session.commit()
return asset.assetid
def _rows(client, headers, query=''):
response = client.get(MIGRATION_URL + query, headers=headers)
assert response.status_code == 200, response.get_data(as_text=True)
return response.get_json()['data']
def _by_host(data):
return {row['hostname']: row for row in data['computers']}
def test_readiness_states(client, db, app, auth_headers):
from plugins.endpoints.api.agentauth import generate_credential
from plugins.endpoints.api.enrollment import issue_credential
from plugins.endpoints.models import EndpointCommand, EndpointEnrollRequest
_publish_agent()
enrolled = _pc(db, 'PC-ENROLLED')
issue_credential(enrolled, 'auto')
pending = _pc(db, 'PC-PENDING')
db.session.add(EndpointEnrollRequest(
hostname='pc-pending', serialnumber='SER', enrollnonce=generate_credential(),
status='pending', reason='awaiting approval', matchedassetid=pending,
requestedat=_now(), lastseenat=_now()))
upgrading = _pc(db, 'PC-UPGRADING')
_pc(db, 'PC-READY')
_pc(db, 'PC-BLOCKED', serialnumber=None, reportedago=timedelta(days=3))
_pc(db, 'PC-SHAREONLY', reports=False)
_pc(db, 'PC-NEW', agentversion='1.20.0.0')
db.session.commit()
seeded_app = agentregistry.register_current_agent_package()
assert seeded_app is not None
scriptid = agentupgradescript.seed_upgrade_script()['scriptid']
db.session.add(EndpointCommand(assetid=upgrading, action='run_script',
target=f'script:{scriptid}',
status=EndpointCommand.STATUS_PENDING))
db.session.commit()
data = _rows(client, auth_headers)
rows = _by_host(data)
assert data['target'] == '1.20.0'
assert rows['PC-ENROLLED']['state'] == 'enrolled'
assert rows['PC-PENDING']['state'] == 'pending'
assert rows['PC-UPGRADING']['state'] == 'upgrading'
assert rows['PC-READY']['state'] == 'ready'
assert rows['PC-READY']['blockers'] == []
assert rows['PC-BLOCKED']['state'] == 'blocked'
assert rows['PC-BLOCKED']['blockers'] == ['offline', 'no serial on record']
assert rows['PC-SHAREONLY']['state'] == 'notreporting'
assert rows['PC-NEW']['state'] == 'blocked'
assert rows['PC-NEW']['blockers'] == ['awaiting enrollment']
assert data['counts'] == {'enrolled': 1, 'pending': 1, 'upgrading': 1,
'ready': 1, 'blocked': 2, 'notreporting': 1}
only = _rows(client, auth_headers, '?state=ready')
assert [row['hostname'] for row in only['computers']] == ['PC-READY']
assert client.get(MIGRATION_URL + '?state=bogus',
headers=auth_headers).status_code == 400
def test_every_blocker_is_listed(client, db, app, auth_headers, monkeypatch):
from plugins.endpoints import commands
from plugins.endpoints.api import enrollment
# No package in the store at all.
_pc(db, 'DUP')
_pc(db, 'dup', agentversion='1.5.0')
lockedid = _pc(db, 'LOCKED')
commands.PLACE_ENDPOINT_LOCK(assetid=lockedid, reason='bench repair')
db.session.commit()
monkeypatch.setattr(enrollment, 'minimum_agent_version_for',
lambda action: '1.10.0')
rows = {}
for row in _rows(client, auth_headers)['computers']:
rows.setdefault(row['hostname'], []).append(row)
assert rows['dup'][0]['blockers'] == ['duplicate hostname',
'no agent package in store',
'agent too old for remote upgrade']
assert rows['LOCKED'][0]['blockers'] == ['commands locked',
'no agent package in store']
def test_an_old_store_build_counts_as_no_package(client, db, app, auth_headers):
_publish_agent('1.19.5')
_pc(db, 'PC-A')
row = _by_host(_rows(client, auth_headers))['PC-A']
assert row['blockers'] == ['no agent package in store']
def test_version_compare_is_numeric(client, db, app, auth_headers):
_publish_agent('1.20.0')
_pc(db, 'PC-NINE', agentversion='1.9.0')
_pc(db, 'PC-HUNDRED', agentversion='1.100.0')
rows = _by_host(_rows(client, auth_headers))
assert rows['PC-NINE']['state'] == 'ready'
assert rows['PC-HUNDRED']['blockers'] == ['awaiting enrollment']
def test_wave_next_n_queues_ready_pcs_in_hostname_order(client, db, app,
auth_headers):
from plugins.endpoints.models import EndpointCommand, EndpointMigrationWave
_publish_agent()
third = _pc(db, 'PC-C')
first = _pc(db, 'PC-A')
second = _pc(db, 'PC-B')
_pc(db, 'PC-OFFLINE', reportedago=timedelta(days=2))
response = client.post(WAVE_URL, json={'next': 2}, headers=auth_headers)
assert response.status_code == 201, response.get_data(as_text=True)
data = response.get_json()['data']
assert data['queued'] == [first, second]
assert data['skipped'] == []
commands = EndpointCommand.query.filter_by(action='run_script').all()
assert sorted(c.assetid for c in commands) == [first, second]
wave = EndpointMigrationWave.query.one()
assert wave.waveid == data['waveid']
assert wave.assetidlist == [first, second]
migration = _rows(client, auth_headers)
rows = _by_host(migration)
assert rows['PC-A']['state'] == 'upgrading'
assert rows['PC-C']['state'] == 'ready'
assert migration['waves'] == [{
'waveid': wave.waveid, 'createdat': migration['waves'][0]['createdat'],
'createdby': 'testadmin', 'size': 2, 'queued': 2, 'enrolled': 0,
'failed': 0, 'done': False}]
assert third not in data['queued']
def test_wave_explicit_ids_rechecks_readiness(client, db, app, auth_headers):
_publish_agent()
ready = _pc(db, 'PC-READY')
offline = _pc(db, 'PC-OFFLINE', reportedago=timedelta(days=2))
noagent = _pc(db, 'PC-NOAGENT', reports=False)
response = client.post(WAVE_URL,
json={'assetids': [ready, offline, noagent, 999999]},
headers=auth_headers)
assert response.status_code == 201, response.get_data(as_text=True)
data = response.get_json()['data']
assert data['queued'] == [ready]
assert data['skipped'] == [
{'assetid': offline, 'reason': 'offline'},
{'assetid': noagent, 'reason': 'notreporting'},
{'assetid': 999999, 'reason': 'not a computer'},
]
def test_wave_packages_once(client, db, app, auth_headers, monkeypatch):
from plugins.endpoints.api import routes
_publish_agent()
ids = [_pc(db, f'PC-{n}') for n in range(3)]
calls = []
real = routes.prepare_agent_upgrade
def counting():
calls.append(1)
return real()
monkeypatch.setattr(routes, 'prepare_agent_upgrade', counting)
response = client.post(WAVE_URL, json={'assetids': ids},
headers=auth_headers)
assert response.status_code == 201
assert response.get_json()['data']['queued'] == ids
assert calls == [1]
def test_wave_progress_counts_enrolled_and_failed(client, db, app,
auth_headers):
from plugins.endpoints.api.enrollment import issue_credential
from plugins.endpoints.models import EndpointCommand
_publish_agent()
good = _pc(db, 'PC-GOOD')
bad = _pc(db, 'PC-BAD')
client.post(WAVE_URL, json={'assetids': [good, bad]}, headers=auth_headers)
issue_credential(good, 'auto')
EndpointCommand.query.filter_by(assetid=bad).one().status = \
EndpointCommand.STATUS_FAILED
db.session.commit()
wave = _rows(client, auth_headers)['waves'][0]
assert (wave['size'], wave['enrolled'], wave['failed'], wave['done']) == \
(2, 1, 1, True)
def test_wave_without_ready_pcs_creates_nothing(client, db, app, auth_headers):
from plugins.endpoints.models import EndpointMigrationWave
offline = _pc(db, 'PC-OFFLINE', reportedago=timedelta(days=2))
response = client.post(WAVE_URL, json={'assetids': [offline]},
headers=auth_headers)
assert response.status_code == 200
assert response.get_json()['data']['waveid'] is None
assert EndpointMigrationWave.query.count() == 0
@pytest.mark.parametrize('body', [{}, {'next': 0}, {'next': 101},
{'assetids': []}, {'assetids': ['1']},
{'assetids': list(range(1, 102))}])
def test_wave_validation(client, db, app, auth_headers, body):
assert client.post(WAVE_URL, json=body,
headers=auth_headers).status_code == 400
def test_a_reenroll_blocked_pc_is_blocked_not_ready(client, db, app,
auth_headers):
from plugins.endpoints.api.enrollment import issue_credential
from plugins.endpoints.models import EndpointEnrollRequest
_publish_agent()
assetid = _pc(db, 'PC-ROLLBACK')
issue_credential(assetid, 'auto')
db.session.commit()
response = client.post(
f'/api/endpoints/enrollment/assets/{assetid}/revoke',
json={'blockreenroll': True, 'reason': 'rollback'},
headers=auth_headers)
assert response.status_code == 200, response.get_data(as_text=True)
# Its agent keeps asking; that request must not turn the row into pending.
db.session.add(EndpointEnrollRequest(
hostname='PC-ROLLBACK', serialnumber='SER', enrollnonce='n' * 40,
status='pending', reason='reenroll blocked', matchedassetid=assetid,
requestedat=_now(), lastseenat=_now()))
db.session.commit()
row = _by_host(_rows(client, auth_headers))['PC-ROLLBACK']
assert row['state'] == 'blocked'
assert row['blockers'] == ['reenroll blocked']
wave = client.post(WAVE_URL, json={'assetids': [assetid]},
headers=auth_headers).get_json()['data']
assert wave['skipped'] == [{'assetid': assetid,
'reason': 'reenroll blocked'}]
client.post(f'/api/endpoints/enrollment/assets/{assetid}/allow-reenroll',
headers=auth_headers)
assert _by_host(_rows(client, auth_headers))['PC-ROLLBACK']['state'] \
== 'pending'
@pytest.mark.parametrize('message,detail', [
('Script download failed: The SSL connection could not be established, '
'see inner exception.', None),
('Script download failed', {'error': 'The remote certificate is invalid '
'because of errors in the certificate chain: '
'UntrustedRoot'}),
('Invoke-WebRequest: Could not establish trust relationship for the '
'SSL/TLS secure channel.', None),
])
def test_a_tls_trust_failure_blocks_the_pc(client, db, app, auth_headers,
message, detail):
import json
from plugins.endpoints.models import EndpointCommand
_publish_agent()
assetid = _pc(db, 'PC-UNTRUSTED')
otherid = _pc(db, 'PC-OTHERFAIL')
agentregistry.register_current_agent_package()
scriptid = agentupgradescript.seed_upgrade_script()['scriptid']
db.session.add(EndpointCommand(
assetid=assetid, action='run_script', target=f'script:{scriptid}',
status=EndpointCommand.STATUS_FAILED, resultmessage=message,
resultjson=json.dumps(detail) if detail is not None else None,
completedat=_now()))
db.session.add(EndpointCommand(
assetid=otherid, action='run_script', target=f'script:{scriptid}',
status=EndpointCommand.STATUS_FAILED,
resultmessage='Script exited with code 1', completedat=_now()))
db.session.commit()
rows = _by_host(_rows(client, auth_headers))
assert rows['PC-UNTRUSTED']['state'] == 'blocked'
assert rows['PC-UNTRUSTED']['blockers'] == [
'server certificate not trusted by this PC']
assert rows['PC-OTHERFAIL']['state'] == 'ready'
def test_a_later_success_clears_the_tls_blocker(client, db, app, auth_headers):
from plugins.endpoints.models import EndpointCommand
_publish_agent()
assetid = _pc(db, 'PC-FIXED')
agentregistry.register_current_agent_package()
scriptid = agentupgradescript.seed_upgrade_script()['scriptid']
db.session.add(EndpointCommand(
assetid=assetid, action='run_script', target=f'script:{scriptid}',
status=EndpointCommand.STATUS_FAILED,
resultmessage='RemoteCertificateChainErrors', completedat=_now()))
db.session.flush()
db.session.add(EndpointCommand(
assetid=assetid, action='run_script', target='script:999',
status=EndpointCommand.STATUS_SUCCEEDED, completedat=_now()))
db.session.commit()
assert _by_host(_rows(client, auth_headers))['PC-FIXED']['state'] == 'ready'
def test_a_trust_blocked_pc_is_retried_only_by_explicit_id(client, db, app,
auth_headers):
from plugins.endpoints.models import EndpointCommand, EndpointMigrationWave
_publish_agent()
trustonly = _pc(db, 'PC-TRUST')
alsooffline = _pc(db, 'PC-TRUST-OFFLINE', reportedago=timedelta(days=2))
agentregistry.register_current_agent_package()
scriptid = agentupgradescript.seed_upgrade_script()['scriptid']
for assetid in (trustonly, alsooffline):
db.session.add(EndpointCommand(
assetid=assetid, action='run_script', target=f'script:{scriptid}',
status=EndpointCommand.STATUS_FAILED,
resultmessage='UntrustedRoot', completedat=_now()))
db.session.commit()
rows = _by_host(_rows(client, auth_headers))
assert rows['PC-TRUST']['blockers'] == [
'server certificate not trusted by this PC']
assert rows['PC-TRUST-OFFLINE']['blockers'] == [
'offline', 'server certificate not trusted by this PC']
nextwave = client.post(WAVE_URL, json={'next': 10}, headers=auth_headers)
assert nextwave.status_code == 200, nextwave.get_data(as_text=True)
assert nextwave.get_json()['data']['queued'] == []
assert nextwave.get_json()['data']['retried'] == []
assert EndpointMigrationWave.query.count() == 0
response = client.post(WAVE_URL,
json={'assetids': [trustonly, alsooffline]},
headers=auth_headers)
assert response.status_code == 201, response.get_data(as_text=True)
data = response.get_json()['data']
assert data['queued'] == [trustonly]
assert data['retried'] == [trustonly]
assert data['skipped'] == [{
'assetid': alsooffline,
'reason': 'offline, server certificate not trusted by this PC'}]
assert EndpointMigrationWave.query.one().assetidlist == [trustonly]
assert _by_host(_rows(client, auth_headers))['PC-TRUST']['state'] \
== 'upgrading'
def test_a_trust_blocked_pc_still_needs_run_script_policy(client, db, app,
auth_headers,
monkeypatch):
from plugins.endpoints.api import enrollment
from plugins.endpoints.models import EndpointCommand
_publish_agent()
assetid = _pc(db, 'PC-TRUST')
agentregistry.register_current_agent_package()
scriptid = agentupgradescript.seed_upgrade_script()['scriptid']
db.session.add(EndpointCommand(
assetid=assetid, action='run_script', target=f'script:{scriptid}',
status=EndpointCommand.STATUS_FAILED,
resultmessage='UntrustedRoot', completedat=_now()))
db.session.commit()
monkeypatch.setattr(enrollment, 'ALLOWED_ACTIONS_FOR',
lambda assetid=None: set())
data = client.post(WAVE_URL, json={'assetids': [assetid]},
headers=auth_headers).get_json()['data']
assert data['queued'] == []
assert data['skipped'] == [{
'assetid': assetid,
'reason': 'server certificate not trusted by this PC, '
'run_script not permitted by policy'}]
def test_wave_failed_forgets_a_pc_a_later_wave_upgraded(client, db, app,
auth_headers):
from plugins.endpoints.models import EndpointCommand, EndpointState
_publish_agent()
upgraded = _pc(db, 'PC-UPGRADED')
retried = _pc(db, 'PC-RETRIED')
stuck = _pc(db, 'PC-STUCK')
first = client.post(WAVE_URL, json={'assetids': [upgraded, retried, stuck]},
headers=auth_headers)
assert first.status_code == 201, first.get_data(as_text=True)
for command in EndpointCommand.query.all():
command.status = EndpointCommand.STATUS_FAILED
db.session.commit()
assert _rows(client, auth_headers)['waves'][0]['failed'] == 3
second = client.post(WAVE_URL, json={'assetids': [upgraded, retried]},
headers=auth_headers)
assert second.status_code == 201, second.get_data(as_text=True)
# The later wave replaced the agent on PC-UPGRADED, which restarted before
# reporting, so its command expired; it has not enrolled yet. PC-RETRIED's
# push reported success and is waiting to enroll on its old version string.
latest = {command.assetid: command for command in EndpointCommand.query
.filter_by(status=EndpointCommand.STATUS_PENDING).all()}
latest[upgraded].status = EndpointCommand.STATUS_EXPIRED
latest[retried].status = EndpointCommand.STATUS_SUCCEEDED
EndpointState.query.filter_by(assetid=upgraded).one().agentversion = '1.20.0'
db.session.commit()
failed = {wave['waveid']: wave['failed']
for wave in _rows(client, auth_headers)['waves']}
assert failed == {first.get_json()['data']['waveid']: 1,
second.get_json()['data']['waveid']: 0}
# A newer failure counts again, in every wave the PC belongs to.
latest[retried].status = EndpointCommand.STATUS_FAILED
db.session.commit()
failed = {wave['waveid']: wave['failed']
for wave in _rows(client, auth_headers)['waves']}
assert failed == {first.get_json()['data']['waveid']: 2,
second.get_json()['data']['waveid']: 1}

View File

@@ -0,0 +1,114 @@
"""Fleet alert scheduling respects starttime and endtime.
Regression test for a bug where fleet alerts with a future starttime were sent
immediately instead of waiting for the scheduled time. The agent polls on the
collector credential, and the hostname must resolve to a Computer, so the
fixtures mirror test_endpoints_fleet_alerts.py.
"""
from datetime import datetime, timedelta, timezone
from shopdb.core.models import Asset, AssetType
ALERTS_URL = '/api/endpoints/alerts'
COLLECTOR_KEY = 'testcollectorkey'
def _collector_headers(app):
app.config['COLLECTOR_API_KEY'] = COLLECTOR_KEY
return {'X-API-Key': COLLECTOR_KEY}
def _computer(db, hostname):
from plugins.computers.models import Computer
atype = AssetType.query.filter_by(assettype='computer').first()
if not atype:
atype = AssetType(assettype='computer')
db.session.add(atype)
db.session.flush()
asset = Asset(assetnumber=hostname, assettypeid=atype.assettypeid,
isactive=True)
db.session.add(asset)
db.session.flush()
db.session.add(Computer(assetid=asset.assetid, hostname=hostname))
db.session.commit()
return asset
def _fleet_alert(db, message, starttime=None, endtime=None):
from plugins.notifications.models import Notification, NotificationType
ntype = NotificationType.query.filter_by(typename='Fleet Alert').first()
if not ntype:
ntype = NotificationType(typename='Fleet Alert', typecolor='#ffc107',
isactive=True)
db.session.add(ntype)
db.session.flush()
note = Notification(notification=message,
notificationtypeid=ntype.notificationtypeid,
starttime=starttime, endtime=endtime, isactive=True,
alertfleet=True, alertscope=None, alerttimeout=0)
db.session.add(note)
db.session.commit()
return note
def _now():
return datetime.now(timezone.utc).replace(tzinfo=None)
def _poll(client, app, hostname):
response = client.get(f'{ALERTS_URL}?hostname={hostname}',
headers=_collector_headers(app))
assert response.status_code == 200, response.get_data(as_text=True)
body = response.get_json()
assert body['status'] == 'success'
return body['data']
def test_fleet_alert_respects_future_starttime(client, db, app):
"""A future starttime keeps the alert off the endpoint until it passes."""
_computer(db, 'TEST-PC-01')
now = _now()
note = _fleet_alert(db, 'Scheduled maintenance at 3:30 PM',
starttime=now + timedelta(minutes=20),
endtime=now + timedelta(hours=2))
assert _poll(client, app, 'TEST-PC-01') == []
note.starttime = now - timedelta(minutes=5)
db.session.commit()
alerts = _poll(client, app, 'TEST-PC-01')
assert [a['notificationid'] for a in alerts] == [note.notificationid]
assert alerts[0]['title'] == 'Scheduled maintenance at 3:30 PM'
def test_fleet_alert_with_no_starttime_appears_immediately(client, db, app):
_computer(db, 'TEST-PC-02')
note = _fleet_alert(db, 'Immediate alert - urgent')
alerts = _poll(client, app, 'TEST-PC-02')
assert [a['notificationid'] for a in alerts] == [note.notificationid]
assert alerts[0]['title'] == 'Immediate alert - urgent'
def test_fleet_alert_past_starttime_appears(client, db, app):
_computer(db, 'TEST-PC-03')
note = _fleet_alert(db, 'Alert started 1 hour ago',
starttime=_now() - timedelta(hours=1))
alerts = _poll(client, app, 'TEST-PC-03')
assert [a['notificationid'] for a in alerts] == [note.notificationid]
def test_fleet_alert_expired_does_not_appear(client, db, app):
_computer(db, 'TEST-PC-04')
now = _now()
_fleet_alert(db, 'This alert already ended',
starttime=now - timedelta(hours=2),
endtime=now - timedelta(hours=1))
assert _poll(client, app, 'TEST-PC-04') == []

View File

@@ -0,0 +1,504 @@
"""Fleet alert delivery to the endpoint agent, and terminal acknowledgement.
A fleet alert is a notification flagged alertfleet. The endpoint agent polls
GET /api/endpoints/alerts?hostname=... on the endpoints collector key - the same
credential and kill switch as the rest of the agent channel, because a shop
floor bay has no user session - and the tray surfaces what comes back. When the
operator dismisses one, the agent posts it to the acknowledge route.
Two contracts matter here and both are load-bearing for the durable agent-side
surface:
1. The poll response is AUTHORITATIVE for that host. It is every alert that
should be live on the machine right now, so the agent can retire a pending
alert that has disappeared from it. That is the only way a server-side
close or cancel reaches an endpoint already displaying the alert.
2. Acknowledgement is TERMINAL for an asset. Once acked, the alert never comes
back for that machine, and a later server-side close is a no-op. This is
what lets the agent retry a queued acknowledgement safely.
The cross-machine guard gets its own test because it regressed into an
AttributeError: the handler read .assetid off _resolve_asset_for_hostname,
which returns the id itself, so the check raised on every call and one bay could
not be proven unable to acknowledge another bay's alert.
"""
from datetime import timedelta
from shopdb.core.models import Asset, AssetType
ALERTS_URL = '/api/endpoints/alerts'
ACK_URL = '/api/endpoints/alerts/{}/acknowledge'
COLLECTOR_KEY = 'testcollectorkey'
def _collector_headers(app):
app.config['COLLECTOR_API_KEY'] = COLLECTOR_KEY
return {'X-API-Key': COLLECTOR_KEY}
def _computer(db, hostname):
"""Create a computer Asset + Computer; return the asset."""
from plugins.computers.models import Computer, ComputerType
atype = AssetType.query.filter_by(assettype='computer').first()
if not atype:
atype = AssetType(assettype='computer')
db.session.add(atype)
db.session.flush()
ctype = ComputerType.query.filter_by(computertype='CMM PC').first()
if not ctype:
ctype = ComputerType(computertype='CMM PC')
db.session.add(ctype)
db.session.flush()
asset = Asset(assetnumber=hostname, assettypeid=atype.assettypeid,
isactive=True)
db.session.add(asset)
db.session.flush()
comp = Computer(assetid=asset.assetid, hostname=hostname,
computertypeid=ctype.computertypeid)
db.session.add(comp)
db.session.commit()
return asset
def _alert_type(db):
from plugins.notifications.models import NotificationType
ntype = NotificationType.query.filter_by(typename='Fleet Alert').first()
if not ntype:
ntype = NotificationType(typename='Fleet Alert', typecolor='#d93f3f',
isactive=True)
db.session.add(ntype)
db.session.commit()
return ntype
def _fleet_alert(db, message='Line 3 is down', isactive=True, endtime=None,
timeout=60):
from plugins.notifications.models import Notification
ntype = _alert_type(db)
note = Notification(notificationtypeid=ntype.notificationtypeid,
notification=message, alertfleet=True,
alerttimeout=timeout, isactive=isactive,
endtime=endtime)
db.session.add(note)
db.session.commit()
return note
def _naive_utcnow():
from datetime import datetime, timezone
return datetime.now(timezone.utc).replace(tzinfo=None)
def test_agent_polls_its_active_fleet_alerts(client, db, app):
"""An active fleet alert reaches the endpoint that asks for it."""
_computer(db, 'BAY-01')
alert = _fleet_alert(db, message='Line 3 is down')
resp = client.get(f'{ALERTS_URL}?hostname=BAY-01',
headers=_collector_headers(app))
assert resp.status_code == 200, resp.get_data(as_text=True)
alerts = resp.get_json()['data']
assert [a['notificationid'] for a in alerts] == [alert.notificationid]
assert alerts[0]['message'] == 'Line 3 is down'
assert alerts[0]['timeout'] == 60
# The tray renders the type colour, so it has to come down with the alert
# rather than being guessed client side.
assert alerts[0]['typecolor'] == '#d93f3f'
def test_the_feed_still_carries_the_legacy_camelcase_keys(client, db, app):
"""TRANSITION for trays older than 1.7.0.
A deployed 1.6.x tray fetches this feed itself and deserializes
case-sensitively, so serving only the lowercase keys would make every alert
disappear on those PCs with no error anywhere. Both spellings go down, with
identical values, until the fleet is upgraded - then this test and the two
camelCase keys go together.
"""
_computer(db, 'BAY-01')
alert = _fleet_alert(db, message='Line 3 is down')
resp = client.get(f'{ALERTS_URL}?hostname=BAY-01',
headers=_collector_headers(app))
row = resp.get_json()['data'][0]
assert row['notificationId'] == row['notificationid'] == alert.notificationid
assert row['typeColor'] == row['typecolor'] == '#d93f3f'
def test_poll_requires_the_collector_key(client, db, app):
"""The alert feed is agent-facing, not anonymous."""
_computer(db, 'BAY-01')
_fleet_alert(db)
# Configure the key here rather than inheriting it from whichever test ran
# first: under xdist this file is split across workers, and an unconfigured
# key answers 500 instead of the 401 the test is about.
app.config['COLLECTOR_API_KEY'] = COLLECTOR_KEY
resp = client.get(f'{ALERTS_URL}?hostname=BAY-01')
assert resp.status_code in (401, 403), resp.get_data(as_text=True)
def test_unknown_hostname_is_not_found(client, db, app):
"""A hostname that resolves to no asset is a 404, not an empty list."""
resp = client.get(f'{ALERTS_URL}?hostname=NOSUCHBAY',
headers=_collector_headers(app))
assert resp.status_code == 404
def test_deactivated_alert_drops_out_of_the_feed(client, db, app):
"""Server-side close: the alert stops being offered to the endpoint.
This is how a cancel reaches a machine. The feed is authoritative, so an
alert missing from it is the agent's instruction to retire the pending
toast it is holding.
"""
_computer(db, 'BAY-01')
alert = _fleet_alert(db)
alert.isactive = False
db.session.commit()
resp = client.get(f'{ALERTS_URL}?hostname=BAY-01',
headers=_collector_headers(app))
assert resp.status_code == 200
assert resp.get_json()['data'] == []
def test_expired_alert_drops_out_of_the_feed(client, db, app):
"""An alert past its endtime is withdrawn the same way a closed one is."""
_computer(db, 'BAY-01')
_fleet_alert(db, endtime=_naive_utcnow() - timedelta(minutes=5))
resp = client.get(f'{ALERTS_URL}?hostname=BAY-01',
headers=_collector_headers(app))
assert resp.status_code == 200
assert resp.get_json()['data'] == []
def test_acknowledgement_removes_the_alert_for_that_asset(client, db, app):
"""Acknowledgement is terminal: the alert never returns for this machine."""
asset = _computer(db, 'BAY-01')
alert = _fleet_alert(db)
headers = _collector_headers(app)
ack = client.post(ACK_URL.format(alert.notificationid),
json={'hostname': 'BAY-01', 'assetid': asset.assetid},
headers=headers)
assert ack.status_code == 201, ack.get_data(as_text=True)
resp = client.get(f'{ALERTS_URL}?hostname=BAY-01', headers=headers)
assert resp.get_json()['data'] == []
def test_acknowledgement_is_idempotent(client, db, app):
"""The agent retries a queued acknowledgement, so a repeat must succeed.
Acknowledgement is terminal and the agent holds it on disk until the server
confirms, which means the same ack can legitimately arrive twice after a
network outage. The second one is a success, not a duplicate-key error.
"""
from plugins.notifications.models import NotificationAcknowledgement
asset = _computer(db, 'BAY-01')
alert = _fleet_alert(db)
headers = _collector_headers(app)
payload = {'hostname': 'BAY-01', 'assetid': asset.assetid}
first = client.post(ACK_URL.format(alert.notificationid), json=payload,
headers=headers)
second = client.post(ACK_URL.format(alert.notificationid), json=payload,
headers=headers)
assert first.status_code == 201
assert second.status_code == 200, second.get_data(as_text=True)
assert second.get_json()['data']['acknowledged'] is True
rows = (NotificationAcknowledgement.query
.filter_by(notificationid=alert.notificationid,
assetid=asset.assetid).all())
assert len(rows) == 1, 'a retried acknowledgement wrote a second row'
def test_closing_an_acknowledged_alert_changes_nothing(client, db, app):
"""A server-side close after acknowledgement is a no-op.
Acknowledgement already retired the alert for this asset, so there is
nothing left to withdraw and the alert must not reappear.
"""
asset = _computer(db, 'BAY-01')
alert = _fleet_alert(db)
headers = _collector_headers(app)
client.post(ACK_URL.format(alert.notificationid),
json={'hostname': 'BAY-01', 'assetid': asset.assetid},
headers=headers)
alert.isactive = False
db.session.commit()
resp = client.get(f'{ALERTS_URL}?hostname=BAY-01', headers=headers)
assert resp.status_code == 200
assert resp.get_json()['data'] == []
def test_one_bay_cannot_acknowledge_for_another(client, db, app):
"""The hostname must match the assetid it claims.
This guard regressed into an AttributeError and never actually ran, so it
is pinned: the collector key is shared across the fleet, and without this
check any endpoint could clear an alert on another machine's behalf.
"""
_computer(db, 'BAY-01')
other = _computer(db, 'BAY-02')
alert = _fleet_alert(db)
resp = client.post(ACK_URL.format(alert.notificationid),
json={'hostname': 'BAY-01', 'assetid': other.assetid},
headers=_collector_headers(app))
assert resp.status_code == 400, resp.get_data(as_text=True)
# And BAY-02's alert is untouched by the attempt.
feed = client.get(f'{ALERTS_URL}?hostname=BAY-02',
headers=_collector_headers(app))
assert [a['notificationid'] for a in feed.get_json()['data']] == [
alert.notificationid]
def test_acknowledging_one_bay_leaves_the_others_alerted(client, db, app):
"""A fleet alert is per-asset: one operator does not clear it for everyone."""
asset = _computer(db, 'BAY-01')
_computer(db, 'BAY-02')
alert = _fleet_alert(db)
headers = _collector_headers(app)
client.post(ACK_URL.format(alert.notificationid),
json={'hostname': 'BAY-01', 'assetid': asset.assetid},
headers=headers)
still = client.get(f'{ALERTS_URL}?hostname=BAY-02', headers=headers)
assert [a['notificationid'] for a in still.get_json()['data']] == [
alert.notificationid]
def test_acknowledge_requires_the_collector_key(client, db, app):
"""Acknowledgement is agent-facing and must not answer an anonymous caller."""
asset = _computer(db, 'BAY-01')
alert = _fleet_alert(db)
resp = client.post(ACK_URL.format(alert.notificationid),
json={'hostname': 'BAY-01', 'assetid': asset.assetid})
assert resp.status_code in (401, 403), resp.get_data(as_text=True)
def test_acknowledging_an_unknown_notification_is_not_found(client, db, app):
"""A stale alert id from a rebuilt server is a 404, not a 500."""
asset = _computer(db, 'BAY-01')
resp = client.post(ACK_URL.format(999999),
json={'hostname': 'BAY-01', 'assetid': asset.assetid},
headers=_collector_headers(app))
assert resp.status_code == 404
def test_non_fleet_notification_is_never_delivered(client, db, app):
"""Ordinary board notifications are not agent alerts and must not leak."""
from plugins.notifications.models import Notification
_computer(db, 'BAY-01')
ntype = _alert_type(db)
db.session.add(Notification(notificationtypeid=ntype.notificationtypeid,
notification='Board only', alertfleet=False,
isactive=True))
db.session.commit()
resp = client.get(f'{ALERTS_URL}?hostname=BAY-01',
headers=_collector_headers(app))
assert resp.status_code == 200
assert resp.get_json()['data'] == []
# --- targeting -------------------------------------------------------------
#
# alertscope narrows a fleet alert to part of the floor. It was previously
# stored, serialised and editable in the authoring form but never read here, so
# every alert went to every endpoint no matter what was entered - a control that
# looked like it worked and did not. These pin the behaviour it should have had.
def _ids(client, app, hostname):
resp = client.get(f'{ALERTS_URL}?hostname={hostname}',
headers=_collector_headers(app))
assert resp.status_code == 200, resp.get_data(as_text=True)
return [a['notificationid'] for a in resp.get_json()['data']]
def test_no_scope_still_means_the_whole_fleet(client, db, app):
"""The default is unchanged: an unscoped alert reaches everyone."""
_computer(db, 'BAY-01')
_computer(db, 'BAY-02')
alert = _fleet_alert(db)
assert _ids(client, app, 'BAY-01') == [alert.notificationid]
assert _ids(client, app, 'BAY-02') == [alert.notificationid]
def test_an_empty_scope_means_the_whole_fleet(client, db, app):
"""An empty dict is not a match-nothing."""
_computer(db, 'BAY-01')
alert = _fleet_alert(db)
alert.alertscope = {}
db.session.commit()
assert _ids(client, app, 'BAY-01') == [alert.notificationid]
def test_hostname_scope_targets_one_endpoint(client, db, app):
"""The narrowest case, and the one the test card drives."""
_computer(db, 'BAY-01')
_computer(db, 'BAY-02')
alert = _fleet_alert(db)
alert.alertscope = {'hostname': 'BAY-01'}
db.session.commit()
assert _ids(client, app, 'BAY-01') == [alert.notificationid]
assert _ids(client, app, 'BAY-02') == []
def test_hostname_scope_is_case_insensitive(client, db, app):
"""Operators type these by hand; case must not decide delivery."""
_computer(db, 'BAY-01')
alert = _fleet_alert(db)
alert.alertscope = {'hostname': 'bay-01'}
db.session.commit()
assert _ids(client, app, 'BAY-01') == [alert.notificationid]
def test_a_scope_may_list_several_values(client, db, app):
"""Any value in a list matches, so a scope can name a handful of bays."""
_computer(db, 'BAY-01')
_computer(db, 'BAY-02')
_computer(db, 'BAY-03')
alert = _fleet_alert(db)
alert.alertscope = {'hostname': ['BAY-01', 'BAY-03']}
db.session.commit()
assert _ids(client, app, 'BAY-01') == [alert.notificationid]
assert _ids(client, app, 'BAY-02') == []
assert _ids(client, app, 'BAY-03') == [alert.notificationid]
def test_computertype_scope_targets_a_class_of_machine(client, db, app):
"""Targeting by what the machine is, not which one it is."""
_computer(db, 'BAY-01')
alert = _fleet_alert(db)
alert.alertscope = {'computertype': 'CMM PC'}
db.session.commit()
assert _ids(client, app, 'BAY-01') == [alert.notificationid]
alert.alertscope = {'computertype': 'Kiosk'}
db.session.commit()
assert _ids(client, app, 'BAY-01') == []
def test_several_scope_keys_must_all_match(client, db, app):
"""Keys narrow together - it is an AND, not an OR."""
_computer(db, 'BAY-01')
alert = _fleet_alert(db)
alert.alertscope = {'hostname': 'BAY-01', 'computertype': 'CMM PC'}
db.session.commit()
assert _ids(client, app, 'BAY-01') == [alert.notificationid]
# Right host, wrong type: the alert must not land.
alert.alertscope = {'hostname': 'BAY-01', 'computertype': 'Kiosk'}
db.session.commit()
assert _ids(client, app, 'BAY-01') == []
def test_an_unknown_scope_key_is_ignored_not_fatal(client, db, app):
"""A typo must not silently mute an alert everywhere.
Scopes are authored by hand. If {"hostnam": "BAY-01"} matched nothing, the
alert would vanish fleet-wide and look like a delivery failure. Sending it
wider than intended is the safe direction for something whose job is to warn
people, so unrecognised keys are ignored.
"""
_computer(db, 'BAY-01')
alert = _fleet_alert(db)
alert.alertscope = {'hostnam': 'BAY-01'}
db.session.commit()
assert _ids(client, app, 'BAY-01') == [alert.notificationid]
def test_a_non_dict_scope_fails_closed(client, db, app):
"""A scope that is present but not an object is corrupt, not fleet-wide.
Create/update refuse such a scope, so a row carrying one did not come from
an operator's intent to reach everyone. Broadcasting it could put a
one-bay alert on every screen, so it reaches nobody.
"""
_computer(db, 'BAY-01')
alert = _fleet_alert(db)
alert.alertscope = 'BAY-01'
db.session.commit()
assert _ids(client, app, 'BAY-01') == []
alert.alertscope = ['BAY-01']
db.session.commit()
assert _ids(client, app, 'BAY-01') == []
def test_a_json_string_scope_is_parsed(client, db, app):
"""Legacy rows written through the TEXT column hold the object as text."""
_computer(db, 'BAY-01')
_computer(db, 'BAY-02')
alert = _fleet_alert(db)
alert.alertscope = '{"hostname": "BAY-01"}'
db.session.commit()
assert _ids(client, app, 'BAY-01') == [alert.notificationid]
assert _ids(client, app, 'BAY-02') == []
def test_scope_keys_match_the_notifications_validator():
"""Create/update validation and the matcher must agree on the key set."""
from plugins.endpoints.api.routes import _ALERT_SCOPE_KEYS
from plugins.notifications.models.notification import ALERT_SCOPE_KEYS
assert set(_ALERT_SCOPE_KEYS) == set(ALERT_SCOPE_KEYS)
def test_scoping_does_not_leak_into_acknowledgement(client, db, app):
"""A targeted alert is acknowledged the same way any other one is."""
asset = _computer(db, 'BAY-01')
alert = _fleet_alert(db)
alert.alertscope = {'hostname': 'BAY-01'}
db.session.commit()
ack = client.post(ACK_URL.format(alert.notificationid),
json={'hostname': 'BAY-01', 'assetid': asset.assetid},
headers=_collector_headers(app))
assert ack.status_code == 201
assert _ids(client, app, 'BAY-01') == []

View File

@@ -0,0 +1,110 @@
"""The Fleet list must carry the asset alias.
The alias is what people actually call a PC ("Bay 3 Controller"); the hostname
is what DNS calls it. The PCs list has shown both for a long time, but the
Fleet list query never selected Asset.name, so the column could not be built
even though the data existed - it was missing rather than broken.
These pin the field on the payload and its inclusion in search, so the column
cannot quietly lose its source again.
"""
LIST_URL = '/api/endpoints/'
def _endpoint(db, hostname='fleetbox', alias='Bay 3 Controller'):
"""An Asset + Computer + EndpointState triple: one row on the fleet list.
The list joins through Computer and filters on Asset.isactive, so all three
are required for a row to appear at all.
"""
from shopdb.api import Asset, AssetType
from plugins.computers.models import Computer
from plugins.endpoints.models import EndpointState
assettype = AssetType.query.filter_by(assettype='computer').first()
if assettype is None:
assettype = AssetType(assettype='computer', pluginname='computers',
tablename='computers')
db.session.add(assettype)
db.session.flush()
asset = Asset(assetnumber=hostname, name=alias,
assettypeid=assettype.assettypeid, isactive=True)
db.session.add(asset)
db.session.flush()
db.session.add(Computer(assetid=asset.assetid, hostname=hostname))
db.session.add(EndpointState(assetid=asset.assetid, hostname=hostname))
db.session.commit()
return asset.assetid
def _rows(response):
return response.get_json()['data']
def test_the_fleet_list_carries_the_alias(client, db, auth_headers):
_endpoint(db)
response = client.get(LIST_URL, headers=auth_headers)
assert response.status_code == 200, response.get_json()
rows = _rows(response)
assert len(rows) == 1
# Exposed as 'alias', not 'name': the row already has a hostname, and
# 'name' beside 'hostname' reads as a duplicate of the same thing.
assert rows[0]['alias'] == 'Bay 3 Controller'
def test_an_endpoint_without_an_alias_still_lists(client, db, auth_headers):
# The column renders '-' for these. A null must not drop the row or 500.
_endpoint(db, hostname='namelessbox', alias=None)
response = client.get(LIST_URL, headers=auth_headers)
assert response.status_code == 200, response.get_json()
rows = _rows(response)
assert len(rows) == 1
assert rows[0]['alias'] is None
assert rows[0]['hostname'] == 'namelessbox'
def test_the_alias_is_searchable(client, db, auth_headers):
# Someone looking for a PC types what it is called, not its hostname.
_endpoint(db, hostname='pc-a', alias='Bay 3 Controller')
_endpoint(db, hostname='pc-b', alias='Paint Booth')
response = client.get(LIST_URL + '?search=Bay 3', headers=auth_headers)
assert response.status_code == 200, response.get_json()
rows = _rows(response)
assert [r['hostname'] for r in rows] == ['pc-a']
def test_searching_by_hostname_still_works(client, db, auth_headers):
# The alias clause was added to an existing OR; prove it did not replace
# what was already there.
_endpoint(db, hostname='pc-a', alias='Bay 3 Controller')
_endpoint(db, hostname='pc-b', alias='Paint Booth')
response = client.get(LIST_URL + '?search=pc-b', headers=auth_headers)
assert response.status_code == 200, response.get_json()
rows = _rows(response)
assert [r['hostname'] for r in rows] == ['pc-b']
def test_the_row_keeps_its_other_columns(client, db, auth_headers):
# The alias was added mid-tuple in the query, so every following column
# shifted position. If that unpacking is wrong the values silently swap
# rather than raising.
_endpoint(db, hostname='fleetbox', alias='Bay 3 Controller')
response = client.get(LIST_URL, headers=auth_headers)
row = _rows(response)[0]
assert row['hostname'] == 'fleetbox'
assert row['assetnumber'] == 'fleetbox'
assert row['alias'] == 'Bay 3 Controller'
assert row['status'] in ('never', 'quiet', 'reporting')
assert 'computerid' in row and row['computerid'] is not None

View File

@@ -0,0 +1,320 @@
"""Health rollup: conditions outrank event counts, and no policy still works.
The bug this locks down: the Fleet Health column ranked a raw Windows event
count above every other signal, so a box with a crash loop reported
"Application error x5861" while its dead eDNC service was structurally
unreachable - the event branch returned before services were ever considered.
The other half is the no-policy path. An endpoint with no policies, no PC type,
and no monitor impact rows is the NORMAL case here, not an edge case, and it
must still produce a verdict. A policy binding may only RAISE impact; it may
never gate whether health is computed at all.
"""
import json
from datetime import datetime, timedelta
import pytest
from shopdb.core.models import Asset, AssetType
from plugins.endpoints.models import (
EndpointState, EndpointEvent, EndpointMonitor,
EndpointHealthState, EndpointHealthTransition, EndpointMonitorImpact,
)
from plugins.endpoints.models.health import (
IMPACT_BLOCKER, IMPACT_DEGRADED, SOURCE_DRIVE, SOURCE_EVENT,
SOURCE_SERVICE,
)
from plugins.endpoints.services.health import (
HEALTH_WINDOW_HOURS, TRANSITION_HIGHWATER, TRANSITION_RETENTION,
evaluate_endpoint_health,
)
NOW = datetime(2025, 1, 15, 12, 0, 0)
# A curated CRITICAL id, so the event path is genuinely exercised rather than
# filtered out by the catalog. 1000 is 'Application error'; note 1001 was
# deliberately demoted to informational and would not surface here.
CRASH_EVENTID = 1000
@pytest.fixture
def endpoint(db):
"""One computer asset with a state row. No policies, no PC type."""
assettype = AssetType.query.filter_by(assettype='computer').first()
if not assettype:
assettype = AssetType(assettype='computer')
db.session.add(assettype)
db.session.flush()
asset = Asset(assetnumber='HEALTH-PC-1', assettypeid=assettype.assettypeid)
db.session.add(asset)
db.session.flush()
state = EndpointState(assetid=asset.assetid, lastreportedat=NOW)
db.session.add(state)
db.session.commit()
return state
def _services(*entries):
return json.dumps(list(entries))
def _drives(*statuses):
"""A usercontext payload with one netuse drive per (letter, status)."""
return json.dumps({
'netuse': [{'localname': letter, 'remotename': r'\\server\share',
'status': status}
for letter, status in statuses]
})
def _events(db, assetid, count, hoursago=1):
for index in range(count):
db.session.add(EndpointEvent(
assetid=assetid,
eventid=CRASH_EVENTID,
eventrecordid=index + 1,
eventchannel='Application',
eventtime=NOW - timedelta(hours=hoursago),
))
db.session.commit()
def _monitor(db, servicename):
monitor = EndpointMonitor(kind='service', displayname=servicename,
servicename=servicename)
db.session.add(monitor)
db.session.flush()
return monitor
def test_no_policy_endpoint_still_gets_a_verdict(db, endpoint):
"""Zero policies, zero monitors, zero impact rows - still resolves.
The whole no-policy path in one assertion: absence of configuration is the
default, not a failure, and it must not read as unknown either.
"""
assert EndpointMonitorImpact.query.count() == 0
assert EndpointMonitor.query.count() == 0
result = evaluate_endpoint_health(endpoint.assetid, endpoint, now=NOW)
db.session.commit()
assert result.level == 'ok'
assert result.impactclass is None
def test_stopped_service_falls_back_to_degraded_without_an_impact_row(
db, endpoint):
"""No EndpointMonitorImpact row means the built-in default applies.
This is the verdict the old rollup gave a stopped watched service, kept
deliberately so shipping the mapping table changes nothing until someone
actually maps something.
"""
endpoint.servicesjson = _services(
{'name': 'eDNC', 'state': 'Stopped', 'monitorid': 4242})
db.session.commit()
result = evaluate_endpoint_health(endpoint.assetid, endpoint, now=NOW)
db.session.commit()
assert result.impactclass == IMPACT_DEGRADED
assert result.level == 'warning'
assert result.label == 'eDNC stopped'
def test_impact_is_a_property_of_the_monitor_in_context(db, endpoint):
"""Policy may RAISE impact, and only for the PC type it was mapped to.
Same service, same telemetry, two PC types: a blocker on the shopfloor
machine that runs parts, merely degraded on one that does not.
"""
monitor = _monitor(db, 'eDNC')
db.session.add(EndpointMonitorImpact(
endpointmonitorid=monitor.endpointmonitorid,
computertypeid=7,
impactclass=IMPACT_BLOCKER,
createdat=NOW))
endpoint.servicesjson = _services(
{'name': 'eDNC', 'state': 'Stopped',
'monitorid': monitor.endpointmonitorid})
db.session.commit()
raised = evaluate_endpoint_health(endpoint.assetid, endpoint,
computertypeid=7, now=NOW)
db.session.commit()
assert raised.impactclass == IMPACT_BLOCKER
assert raised.level == 'critical'
# PC type 9 has no mapping for this monitor, so the default applies.
default = evaluate_endpoint_health(endpoint.assetid, endpoint,
computertypeid=9, now=NOW)
db.session.commit()
assert default.impactclass == IMPACT_DEGRADED
def test_a_dead_drive_outranks_a_large_crash_count(db, endpoint):
"""THE REGRESSION. 200 crashes must not hide a disconnected drive."""
endpoint.usercontextjson = _drives(('S:', 'Disconnected'))
db.session.commit()
_events(db, endpoint.assetid, 200)
result = evaluate_endpoint_health(endpoint.assetid, endpoint, now=NOW)
db.session.commit()
assert result.sourcekind == SOURCE_DRIVE
assert result.impactclass == IMPACT_BLOCKER
assert 'S:' in result.label
def test_a_stopped_service_outranks_an_event_at_equal_impact(db, endpoint):
"""A live condition beats a report of something that already finished."""
endpoint.servicesjson = _services({'name': 'eDNC', 'state': 'Stopped'})
db.session.commit()
_events(db, endpoint.assetid, 50)
result = evaluate_endpoint_health(endpoint.assetid, endpoint, now=NOW)
db.session.commit()
assert result.sourcekind == SOURCE_SERVICE
def test_events_still_surface_when_nothing_is_structurally_wrong(db, endpoint):
"""Demoting events below conditions must not mean discarding them."""
_events(db, endpoint.assetid, 3)
result = evaluate_endpoint_health(endpoint.assetid, endpoint, now=NOW)
db.session.commit()
assert result.sourcekind == SOURCE_EVENT
assert result.label.endswith('x3')
def test_events_outside_the_window_are_not_counted(db, endpoint):
"""The window is a window, not a running total since install."""
_events(db, endpoint.assetid, 5, hoursago=HEALTH_WINDOW_HOURS + 1)
result = evaluate_endpoint_health(endpoint.assetid, endpoint, now=NOW)
db.session.commit()
assert result.level == 'ok'
def test_a_stalled_service_is_distinguished_from_a_stopped_one(db, endpoint):
"""stop-pending is not the same fault as cleanly down, and now says so."""
endpoint.servicesjson = _services(
{'name': 'eDNC', 'state': 'Stop Pending'})
db.session.commit()
result = evaluate_endpoint_health(endpoint.assetid, endpoint, now=NOW)
db.session.commit()
assert result.label == 'eDNC stalled'
def test_malformed_services_reads_as_unknown_not_as_healthy(db, endpoint):
"""Unparseable telemetry means we cannot tell - never claim ok."""
endpoint.servicesjson = '{not json'
db.session.commit()
result = evaluate_endpoint_health(endpoint.assetid, endpoint, now=NOW)
db.session.commit()
assert result.level == 'unknown'
def test_a_ticking_event_count_is_the_same_condition(db, endpoint):
"""x2 -> x3 must not churn history, reset firstseenat, or flap.
Identity is what and where, not the rendered label. If the label drove
this, a crash loop would open a new transition every single check-in.
"""
_events(db, endpoint.assetid, 2)
evaluate_endpoint_health(endpoint.assetid, endpoint, now=NOW)
db.session.commit()
db.session.add(EndpointEvent(
assetid=endpoint.assetid, eventid=CRASH_EVENTID, eventrecordid=99,
eventchannel='Application', eventtime=NOW - timedelta(hours=1)))
db.session.commit()
# Still inside the 48h window: the condition genuinely persists, so this
# isolates "same condition" from "events aged out".
later = NOW + timedelta(hours=1)
result = evaluate_endpoint_health(endpoint.assetid, endpoint, now=later)
db.session.commit()
assert result.label.endswith('x3')
assert result.firstseenat == NOW
assert EndpointHealthTransition.query.filter_by(
assetid=endpoint.assetid).count() == 1
def test_recovery_resolves_the_open_transition(db, endpoint):
"""A condition is over only when something observes it to be over."""
endpoint.servicesjson = _services({'name': 'eDNC', 'state': 'Stopped'})
db.session.commit()
evaluate_endpoint_health(endpoint.assetid, endpoint, now=NOW)
db.session.commit()
endpoint.servicesjson = _services({'name': 'eDNC', 'state': 'Running'})
later = NOW + timedelta(hours=1)
db.session.commit()
result = evaluate_endpoint_health(endpoint.assetid, endpoint, now=later)
db.session.commit()
assert result.level == 'ok'
opening = (EndpointHealthTransition.query
.filter_by(assetid=endpoint.assetid, sourcekind=SOURCE_SERVICE)
.first())
assert opening.resolvedat == later
def test_transitions_are_capped_but_unresolved_rows_are_never_trimmed(
db, endpoint):
"""History is capped; the row explaining what is on screen still stays.
Flap the service past the high-water mark, then leave it down. Retention
must bound the history AND keep the open transition, because that row is
what the UI reads to say how long the current fault has been running.
"""
for index in range(TRANSITION_HIGHWATER + 6):
running = index % 2 == 0
endpoint.servicesjson = _services(
{'name': 'eDNC', 'state': 'Running' if running else 'Stopped'})
db.session.commit()
evaluate_endpoint_health(
endpoint.assetid, endpoint, now=NOW + timedelta(minutes=index))
db.session.commit()
rows = EndpointHealthTransition.query.filter_by(
assetid=endpoint.assetid).all()
assert TRANSITION_RETENTION <= len(rows) <= TRANSITION_HIGHWATER
unresolved = [row for row in rows if row.resolvedat is None]
assert len(unresolved) == 1
# Flapping is counted, and there is exactly one live verdict throughout.
state = EndpointHealthState.query.filter_by(
assetid=endpoint.assetid).one()
assert state.flapcount > 1
def test_lastconfirmedat_does_not_advance_without_an_observation(db, endpoint):
"""A pinned blocker on a silent endpoint must not look freshly verified."""
endpoint.servicesjson = _services({'name': 'eDNC', 'state': 'Stopped'})
db.session.commit()
evaluate_endpoint_health(endpoint.assetid, endpoint, now=NOW)
db.session.commit()
# Re-evaluated two days later with no new check-in: lastreportedat is
# untouched, so confirmation must not move either.
result = evaluate_endpoint_health(endpoint.assetid, endpoint,
now=NOW + timedelta(days=2))
db.session.commit()
assert result.lastconfirmedat == NOW
assert result.lastevaluatedat == NOW + timedelta(days=2)

View File

@@ -0,0 +1,53 @@
"""Health evaluation failing with a database error must not sink the check-in.
The health rollup is fail-open: a caught exception leaves the previous verdict
standing. A caught IntegrityError from a flush, though, leaves the session
needing a rollback, and the outer commit of the whole check-in would then fail.
The evaluation runs inside a SAVEPOINT so only its own writes are discarded.
"""
from shopdb.core.models import Asset, AssetType
def _computer(db, hostname):
from plugins.computers.models import Computer
atype = AssetType.query.filter_by(assettype='computer').first()
if not atype:
atype = AssetType(assettype='computer')
db.session.add(atype)
db.session.flush()
asset = Asset(assetnumber=hostname, assettypeid=atype.assettypeid,
isactive=True)
db.session.add(asset)
db.session.flush()
db.session.add(Computer(assetid=asset.assetid, hostname=hostname))
db.session.commit()
return asset
def test_integrity_error_in_health_keeps_the_checkin(client, db, app,
monkeypatch):
from plugins.endpoints.plugin import EndpointsPlugin
from plugins.endpoints.models import EndpointState
from plugins.endpoints.services import health
calls = []
def exploding_health(assetid, state, computertypeid=None):
calls.append(assetid)
# A duplicate unique value: the flush raises IntegrityError.
db.session.add(AssetType(assettype='computer'))
db.session.flush()
monkeypatch.setattr(health, 'evaluate_endpoint_health', exploding_health)
asset = _computer(db, 'SAVEPT01')
plugin = EndpointsPlugin()
plugin.apply_collector_payload({'hostname': 'SAVEPT01', 'services': []})
db.session.commit()
assert calls == [asset.assetid]
state = EndpointState.query.filter_by(assetid=asset.assetid).first()
assert state is not None
assert AssetType.query.filter_by(assettype='computer').count() == 1

View File

@@ -0,0 +1,138 @@
"""IP History and IP Configuration, carved out of the old Details Network card.
These read core Communication rows, which accumulate as an ACCIDENTAL history:
_record_interfaces matches an incoming NIC on MAC then on IP and inserts when
neither matches, and never deletes. So an address the machine has not held for
a year sits next to the current one. These tests pin the reading of that, not
the collecting.
The date columns are the delicate part. modifieddate only moves when a field on
the row actually changes, so an address still held and reported unchanged keeps
an old stamp. It is labelled 'Last changed' for that reason and must not drift
back to 'Last seen'.
"""
from datetime import datetime
from pathlib import Path
from plugins.endpoints.api.routes import (
_IPHISTORY_COLUMNS,
_iphistory_status,
_isostamp,
)
class FakeComm:
"""Only the columns these two routes read."""
def __init__(self, ipaddress='10.0.0.1', macaddress='AA:BB',
isprimary=False, ismachinenetwork=False, isdhcp=False,
subnetmask='', gateway='', dns1='', dns2='',
createddate=None, modifieddate=None):
self.ipaddress = ipaddress
self.macaddress = macaddress
self.isprimary = isprimary
self.ismachinenetwork = ismachinenetwork
self.isdhcp = isdhcp
self.subnetmask = subnetmask
self.gateway = gateway
self.dns1 = dns1
self.dns2 = dns2
self.createddate = createddate
self.modifieddate = modifieddate
def test_an_apipa_address_is_named_as_a_dhcp_failure():
# 169.254.x.x is not an address the machine was given - it is what Windows
# assigns when DHCP failed. Calling it 'Previous' would file a past network
# fault as a past subnet.
assert _iphistory_status(FakeComm(ipaddress='169.254.122.88')) == 'APIPA (no DHCP)'
def test_apipa_wins_over_primary():
# A box currently failing DHCP can have its APIPA address flagged primary
# by the collector. The fault is the more useful thing to say.
comm = FakeComm(ipaddress='169.254.1.1', isprimary=True)
assert _iphistory_status(comm) == 'APIPA (no DHCP)'
def test_a_normal_address_is_not_mistaken_for_apipa():
# Guards a substring match: 169.254 must be a prefix, not found anywhere.
assert _iphistory_status(FakeComm(ipaddress='10.169.254.5')) == 'Previous'
def test_the_primary_address_is_marked():
assert _iphistory_status(FakeComm(isprimary=True)) == 'Primary'
def test_a_machine_network_address_is_distinguished_from_a_stale_one():
# ismachinenetwork means it talks to the machine, not that it is old.
assert _iphistory_status(FakeComm(ismachinenetwork=True)) == 'Machine network'
def test_everything_else_reads_as_previous():
assert _iphistory_status(FakeComm()) == 'Previous'
def test_a_missing_timestamp_yields_null_not_a_crash():
# createddate is nullable on legacy rows imported before BaseModel stamps.
assert _isostamp(None) is None
def test_a_timestamp_is_emitted_as_utc_iso():
# Matches every other timestamp this file emits; the client formats it to
# the viewer's locale rather than being pinned to the server's.
stamp = _isostamp(datetime(2026, 9, 5, 11, 30, 0))
assert stamp == '2026-09-05T11:30:00Z'
def test_the_second_date_is_not_labelled_last_seen():
# The whole reason for the label. modifieddate cannot support 'last seen':
# an unchanged address reported daily keeps a months-old stamp.
labels = {col['key']: col['label'] for col in _IPHISTORY_COLUMNS}
assert labels['lastchanged'] == 'Last changed'
assert 'seen' not in labels['lastchanged'].lower()
assert labels['firstseen'] == 'First seen'
def test_the_history_ordering_compiles_on_mysql():
# Regression: the first cut ordered with .nullslast(), which is valid
# ANSI/Postgres but blows up on MySQL with a 1064 syntax error. The test
# suite runs on SQLite, so it passed and the page still 500'd in the
# browser. Compile the real ordering against the MySQL dialect so the
# dialect gap is caught here instead of in a container log.
from sqlalchemy import select
from sqlalchemy.dialects import mysql
from shopdb.api import Communication
ordering = (select(Communication.ipaddress)
.where(Communication.assetid == 1)
.order_by(Communication.modifieddate.is_(None),
Communication.modifieddate.desc(),
Communication.communicationid.desc()))
compiled = str(ordering.compile(dialect=mysql.dialect()))
assert 'NULLS LAST' not in compiled.upper()
def test_every_panel_handler_still_has_a_route():
# Regression: inserting the ipconfig route directly above panel_applications
# ate that function's @endpoints_bp.route line, leaving a handler decorated
# with require_permission only. It imported fine and every test passed; the
# endpoint was simply gone. Assert each panel_* handler is actually mapped.
import re
from plugins.endpoints.api import routes as routes_module
source = Path(routes_module.__file__).read_text(encoding='utf-8')
handlers = re.findall(r'^def (panel_\w+)', source, re.MULTILINE)
assert handlers, 'expected panel handlers in routes.py'
routed = set()
for match in re.finditer(r'^@endpoints_bp\.route\(', source, re.MULTILINE):
tail = source[match.start():]
found = re.search(r'^def (\w+)', tail, re.MULTILINE)
if found:
routed.add(found.group(1))
assert set(handlers) - routed == set()

View File

@@ -0,0 +1,49 @@
"""NIC link label: an unknown link must never read as a working one.
Link speed and duplex are paired onto the interface rows from a second WMI
class, so three states are possible: read successfully, unreadable, and
disconnected. Verified on a live box that a DISCONNECTED adapter returns empty
Speed and FullDuplex - two of four NICs on the test machine were in that state.
That is the trap these tests pin. Defaulting an absent speed to 0 or an absent
duplex to False would report every unplugged NIC as half duplex, which is the
exact signature of the cable/port fault this field exists to catch. A column
that cries fault on every disconnected NIC is a column nobody reads.
"""
from plugins.endpoints.api.routes import _link_label
def test_a_gigabit_full_duplex_link_is_not_annotated():
"""The normal case should be quiet so the abnormal case stands out."""
assert _link_label(1000000000, True) == '1 Gb'
def test_half_duplex_is_called_out_because_it_is_the_actionable_case():
assert _link_label(100000000, False) == '100 Mb half duplex'
def test_a_disconnected_adapter_reads_as_unknown_not_half_duplex():
"""The live-box case: disconnected NICs return empty speed and duplex.
Reading this as half duplex would flag every unplugged NIC as a fault.
"""
assert _link_label(None, None) == '-'
def test_an_unreadable_duplex_on_a_live_link_is_not_assumed_half():
"""Speed known, duplex not. Unknown duplex must not render as a fault."""
assert _link_label(10000000000, None) == '10 Gb'
def test_zero_speed_is_unknown_rather_than_a_zero_speed_link():
assert _link_label(0, True) == '-'
def test_sub_gigabit_speeds_stay_in_megabits():
assert _link_label(10000000, True) == '10 Mb'
def test_speeds_do_not_render_trailing_zeros():
"""2.5Gb NICs are common now; the label should not read '2.5000 Gb'."""
assert _link_label(2500000000, True) == '2.5 Gb'

View File

@@ -0,0 +1,186 @@
"""Mapped-drive merge: two disagreeing Windows sources into one honest row.
Windows reports mapped drives twice - Win32_NetworkConnection (the 'net use'
table) and Get-SmbMapping - and the two do not agree. Captured on one machine at
one moment, same two mappings, Get-SmbMapping said OK for both while net use
said Disconnected and Reconnecting. Trusting the structured source alone would
have rendered a healthy Q: on a box whose Q: was down.
So the probe collects both without reconciling them and _mapped_drives() decides
what an operator is shown. Every test here pins a way that decision could
silently start rendering a broken drive as a working one, which is the only
failure mode of this card that matters: nobody reports a drive that looks fine.
"""
from plugins.endpoints.api.routes import _mapped_drives
def _netuse(localname, remotename, status):
return {'localname': localname, 'remotename': remotename, 'status': status}
def _smb(localpath, remotepath, status):
return {'localpath': localpath, 'remotepath': remotepath, 'status': status}
def test_the_worse_of_two_disagreeing_sources_is_what_gets_shown():
"""The real capture: Get-SmbMapping says OK, net use says Disconnected.
The drive was down. Showing OK because one source said so is the exact bug
this merge exists to prevent.
"""
rows = _mapped_drives({
'netuse': [_netuse('Q:', r'\\server\specdata', 'Disconnected')],
'smbmapping': [_smb('Q:', r'\\server\specdata', 'OK')],
})
assert rows == [r'Q: \\server\specdata (Disconnected)']
def test_reconnecting_is_worse_than_ok_and_survives_the_merge():
"""Reconnecting is a real status from the capture, not a synonym for OK."""
rows = _mapped_drives({
'netuse': [_netuse('S:', r'\\server\shared', 'Reconnecting')],
'smbmapping': [_smb('S:', r'\\server\shared', 'OK')],
})
assert rows == [r'S: \\server\shared (Reconnecting)']
def test_a_drive_both_sources_call_healthy_is_not_annotated():
"""Only problems are labelled; tagging every drive '(OK)' buries the bad one."""
rows = _mapped_drives({
'netuse': [_netuse('H:', r'\\server\home', 'OK')],
'smbmapping': [_smb('H:', r'\\server\home', 'OK')],
})
assert rows == [r'H: \\server\home']
def test_a_drive_only_one_source_knows_about_is_still_reported():
"""Get-SmbMapping is absent on some builds and session types.
Requiring agreement between the sources would hide every mapped drive on
those machines rather than showing what is known about them.
"""
rows = _mapped_drives({
'netuse': [_netuse('P:', r'\\server\programs', 'Disconnected')],
'smbmapping': [],
})
assert rows == [r'P: \\server\programs (Disconnected)']
def test_the_smbmapping_key_missing_entirely_degrades_to_netuse():
"""Old agents predate the second source; the panel must still work.
The server ships ahead of the agent republish, so this is the live shape
until every endpoint has the new probe.
"""
rows = _mapped_drives({
'netuse': [_netuse('Q:', r'\\server\specdata', 'OK')],
})
assert rows == [r'Q: \\server\specdata']
def test_a_drive_with_no_status_from_either_source_reads_as_unknown():
"""A blank annotation reads as healthy, so a failed check must not be blank."""
rows = _mapped_drives({
'netuse': [_netuse('Z:', r'\\server\scratch', None)],
'smbmapping': [],
})
assert rows == [r'Z: \\server\scratch (Unknown)']
def test_an_unrecognised_status_is_not_assumed_healthy():
"""Unknown vocabulary ranks below OK deliberately.
Paired against OK specifically: a status the server does not recognise must
LOSE to a healthy one, or an unseen fault renders green. Pairing it against
a known-bad status would prove nothing, since the known-bad value would win
however unknowns are ranked.
"""
rows = _mapped_drives({
'netuse': [_netuse('X:', r'\\server\thing', 'SomeNewStatus')],
'smbmapping': [_smb('X:', r'\\server\thing', 'OK')],
})
assert rows == [r'X: \\server\thing (SomeNewStatus)']
def test_a_known_bad_status_beats_an_unrecognised_one():
"""Unavailable outranks an unknown string; the worst named state wins."""
rows = _mapped_drives({
'netuse': [_netuse('X:', r'\\server\thing', 'Unavailable')],
'smbmapping': [_smb('X:', r'\\server\thing', 'SomeNewStatus')],
})
assert rows == [r'X: \\server\thing (Unavailable)']
def test_which_source_reported_a_status_does_not_change_the_verdict():
"""The same pair of statuses must resolve identically whichever side sent it.
_worse_drive_status ranks by severity and breaks ties by argument order, so
if two statuses ever rank EQUAL the answer silently depends on which source
happened to report which - and a drive would read healthy or broken based on
enumeration order. Asserting both directions is what makes an accidental tie
visible; a single-direction test passes on luck.
"""
remote = r'\\server\thing'
pairs = [
('OK', 'Disconnected'),
('OK', 'SomeNewStatus'),
('Reconnecting', 'Unavailable'),
('OK', 'Reconnecting'),
]
for first, second in pairs:
forward = _mapped_drives({
'netuse': [_netuse('X:', remote, first)],
'smbmapping': [_smb('X:', remote, second)],
})
backward = _mapped_drives({
'netuse': [_netuse('X:', remote, second)],
'smbmapping': [_smb('X:', remote, first)],
})
assert forward == backward, (
'statuses {!r}/{!r} resolve differently depending on which source '
'reported them'.format(first, second))
def test_the_two_sources_are_paired_by_drive_letter_not_by_order():
"""The sources enumerate independently and need not agree on ordering."""
rows = _mapped_drives({
'netuse': [
_netuse('Q:', r'\\server\specdata', 'OK'),
_netuse('S:', r'\\server\shared', 'OK'),
],
'smbmapping': [
_smb('S:', r'\\server\shared', 'Disconnected'),
_smb('Q:', r'\\server\specdata', 'OK'),
],
})
assert rows == [
r'Q: \\server\specdata',
r'S: \\server\shared (Disconnected)',
]
def test_no_user_context_at_all_yields_no_rows():
"""Nobody signed in is a valid state, not a fault."""
assert _mapped_drives({}) == []
def test_malformed_entries_are_skipped_rather_than_raising():
"""The payload is agent-supplied; a bad row must not blank the whole card."""
rows = _mapped_drives({
'netuse': ['not-a-dict', None, {'status': 'OK'},
_netuse('Q:', r'\\server\specdata', 'Disconnected')],
'smbmapping': 'not-a-list',
})
assert rows == [r'Q: \\server\specdata (Disconnected)']

View File

@@ -0,0 +1,118 @@
"""The mapped drives card: structured rows, badges, and row order.
The merge logic itself is pinned in test_endpoints_mapped_drives.py. These
tests cover what the CARD adds on top: a per-drive badge colour and the row
order that decides what an operator sees first.
The failure mode this guards is silent: a broken drive rendered green, or
sorted below the fold, leaves the card looking healthy and tells nobody.
"""
from plugins.endpoints.api.routes import (
_mapped_drive_rows, _mapped_drives, _mapped_drives_unhealthy_first,
)
def _context(netuse=None, smbmapping=None):
context = {}
if netuse is not None:
context['netuse'] = netuse
if smbmapping is not None:
context['smbmapping'] = smbmapping
return context
def _netuse(local, remote, status):
return {'localname': local, 'remotename': remote, 'status': status}
def test_a_healthy_drive_is_green():
rows = _mapped_drive_rows(_context(netuse=[_netuse('Z:', r'\\srv\share', 'OK')]))
assert rows[0]['statecolor'] == 'green'
assert rows[0]['statuslabel'] == 'OK'
def test_a_disconnected_drive_is_red():
rows = _mapped_drive_rows(
_context(netuse=[_netuse('Z:', r'\\srv\share', 'Disconnected')]))
assert rows[0]['statecolor'] == 'red'
def test_connected_counts_as_healthy_alongside_ok():
"""net use and Get-SmbMapping use different words for the same state."""
rows = _mapped_drive_rows(_context(netuse=[_netuse('Z:', r'\\srv\s', 'Connected')]))
assert rows[0]['statecolor'] == 'green'
def test_a_drive_with_no_status_is_red_not_green():
"""An uncoloured row reads as working. Unknown health is not health."""
rows = _mapped_drive_rows(_context(netuse=[_netuse('Z:', r'\\srv\s', None)]))
assert rows[0]['statuslabel'] == 'Unknown'
assert rows[0]['statecolor'] == 'red'
def test_an_unrecognised_status_is_not_coloured_green():
rows = _mapped_drive_rows(_context(netuse=[_netuse('Z:', r'\\srv\s', 'Weird')]))
assert rows[0]['statecolor'] == 'red'
def test_the_worse_of_two_sources_drives_the_badge_colour():
"""The merge already picks the worse status; the badge must follow it."""
rows = _mapped_drive_rows(_context(
netuse=[_netuse('Z:', r'\\srv\share', 'OK')],
smbmapping=[{'localpath': 'Z:', 'remotepath': r'\\srv\share',
'status': 'Disconnected'}],
))
assert len(rows) == 1
assert rows[0]['statecolor'] == 'red'
def test_the_remote_path_is_carried_as_its_own_field():
"""The card shows the path separately rather than glued into the title."""
rows = _mapped_drive_rows(_context(netuse=[_netuse('Z:', r'\\srv\share', 'OK')]))
assert rows[0]['drive'] == 'Z:'
assert rows[0]['remote'] == r'\\srv\share'
def test_the_summary_line_and_the_card_agree_about_a_broken_drive():
"""Both are built from the same rows, so they cannot drift apart."""
context = _context(netuse=[_netuse('Z:', r'\\srv\share', 'Disconnected')])
assert _mapped_drive_rows(context)[0]['statecolor'] == 'red'
assert '(Disconnected)' in _mapped_drives(context)[0]
def test_a_healthy_drive_is_not_annotated_in_the_summary_line():
context = _context(netuse=[_netuse('Z:', r'\\srv\share', 'OK')])
assert _mapped_drives(context) == [r'Z: \\srv\share']
def test_no_user_context_yields_no_rows():
assert _mapped_drive_rows({}) == []
def test_broken_drives_sort_above_healthy_ones():
"""The broken one must be on screen without scrolling past nine good ones."""
rows = _mapped_drive_rows(_context(netuse=[
_netuse('H:', r'\\srv\home', 'OK'),
_netuse('S:', r'\\srv\shared', 'OK'),
_netuse('Z:', r'\\srv\dead', 'Disconnected'),
]))
ordered = _mapped_drives_unhealthy_first(rows)
assert [row['drive'] for row in ordered] == ['Z:', 'H:', 'S:']
def test_drive_letter_order_survives_within_each_health_group():
"""Sorting only on health keeps the list from reshuffling on refresh.
The unhealthy drives here carry DIFFERENT status words, chosen so that
sorting on the status text would reorder them (Disconnected before
Unavailable) against drive-letter order. A secondary sort key would pass
a test where every broken drive said the same thing.
"""
rows = _mapped_drive_rows(_context(netuse=[
_netuse('A:', r'\\srv\a', 'OK'),
_netuse('B:', r'\\srv\b', 'Unavailable'),
_netuse('C:', r'\\srv\c', 'Connected'),
_netuse('D:', r'\\srv\d', 'Disconnected'),
]))
ordered = _mapped_drives_unhealthy_first(rows)
assert [row['drive'] for row in ordered] == ['B:', 'D:', 'A:', 'C:']

View File

@@ -3,9 +3,11 @@
The tray captures a screenshot and a free-text note; the agent posts them to
POST /api/endpoints/operatorreports on the collector key. The bytes land on
disk under the operator-reports uploads dir and the row holds metadata. The
human-facing panel and image-serve routes are JWT + endpoints.view gated, and
the serve route carries the inert-upload headers. A retention sweep purges rows
and files older than the configured window.
human-facing panel and image-serve routes are JWT + endpoints.operatorreports
gated - deliberately NOT endpoints.view, because the screenshot is an
uncontrolled capture of the operator's screen and reading telemetry does not
imply a right to see it - and the serve route carries the inert-upload headers.
A retention sweep purges rows and files older than the configured window.
"""
import io
@@ -283,3 +285,101 @@ def test_upload_refuses_an_image_over_the_cap(client, db, app):
assert 'limit' in response.get_json()['data']['error']['message']
assert OperatorReport.query.filter_by(
reportedhostname='OPREPORT-HUGE').first() is None
def _viewer_headers(client, db, permissions, username):
"""Log in a user holding exactly `permissions` as direct grants.
Deliberately direct grants rather than a role: endpoints.operatorreports is
in the scope denylist, so a role can never confer it and a role-based
fixture could not express the case these tests need.
"""
from shopdb.core.models import Permission, User
from werkzeug.security import generate_password_hash
user = User(username=username, email=f'{username}@test.local',
passwordhash=generate_password_hash('testpass'))
for name in permissions:
perm = Permission.query.filter_by(name=name).first()
if perm is None:
perm = Permission(name=name, description=name,
category='endpoints')
db.session.add(perm)
db.session.flush()
user.directpermissions.append(perm)
db.session.add(user)
db.session.commit()
response = client.post('/api/auth/login',
json={'username': username, 'password': 'testpass'})
assert response.status_code == 200, response.get_json()
token = response.get_json()['data']['access_token']
return {'Authorization': f'Bearer {token}'}
def _report_for(client, db, app, hostname):
"""Upload one report on the collector key and return its row."""
from plugins.endpoints.models import OperatorReport
asset = _computer(db, hostname)
client.post(UPLOAD_URL,
data={'hostname': hostname, 'details': 'x',
'screenshot': (io.BytesIO(_png_bytes()), 'shot.png')},
content_type='multipart/form-data',
headers=_collector_headers(app))
return OperatorReport.query.filter_by(assetid=asset.assetid).first()
def test_endpoints_view_alone_cannot_fetch_a_report_screenshot(
client, db, app):
"""The whole point of the split: telemetry access is not screenshot access.
The screenshot is an uncontrolled capture of the operator's screen, so a
user who can read a PC's telemetry must NOT get the image for free. If this
regresses, sensitive shopfloor content is exposed to every endpoint viewer.
"""
report = _report_for(client, db, app, 'OPREPORT-VIEWONLY')
headers = _viewer_headers(client, db, ['endpoints.view'], 'telemetryonly')
response = client.get(IMAGE_URL.format(report.operatorreportid),
headers=headers)
assert response.status_code == 403
def test_endpoints_view_alone_cannot_list_operator_reports(client, db, app):
"""The panel route leaks the report text and filenames, so it is gated too.
Hiding the tab in the UI is not a control; the route must refuse directly.
"""
report = _report_for(client, db, app, 'OPREPORT-PANELDENY')
headers = _viewer_headers(client, db, ['endpoints.view'], 'panelviewonly')
response = client.get(PANEL_URL.format(report.assetid), headers=headers)
assert response.status_code == 403
def test_the_operatorreports_grant_opens_both_routes(client, db, app):
"""The positive case: the dedicated grant is sufficient on its own."""
report = _report_for(client, db, app, 'OPREPORT-GRANTED')
headers = _viewer_headers(
client, db, ['endpoints.view', 'endpoints.operatorreports'],
'reportviewer')
image = client.get(IMAGE_URL.format(report.operatorreportid),
headers=headers)
assert image.status_code == 200
panel = client.get(PANEL_URL.format(report.assetid), headers=headers)
assert panel.status_code == 200
reports = panel.get_json()['data']['reports']
assert len(reports) == 1
def test_operator_reports_panel_declares_its_permission():
"""The panel carries the grant so the tab is not advertised without it."""
from plugins.endpoints.plugin import EndpointsPlugin
panels = EndpointsPlugin().get_asset_panels()
panel = next(p for p in panels
if p.get('render') == 'operatorreports')
assert panel['permission'] == 'endpoints.operatorreports'

View File

@@ -0,0 +1,74 @@
"""The Monitors tab split: a panel in a group no tab renders disappears.
This has silently eaten cards twice - once when tracked services moved out of
'actions', once on the 'metrics' to 'monitors' rename. Both times the backend
named a group the frontend did not render, and the panel was not shown, not
logged, and not missed until someone went looking for it.
The split is only correct if four places agree, and only two of them are Python:
1. the panels' group here
2. RENDERED_GROUPS in PluginAssetPanels.vue
3. the tab label map in PCDetail.vue
4. the v-show block that gives the tab a layout
These tests pin the Python half so a later edit cannot quietly move a panel into
a group nothing renders. The frontend half is pinned by its own spec.
"""
from plugins.endpoints.plugin import EndpointsPlugin
from shopdb.core.api.pluginui import ASSET_PANEL_GROUPS
# Derived from the server tuple rather than hand-copied. This used to be a
# literal set duplicating RENDERED_GROUPS, which made it a FIFTH copy of the
# vocabulary and one more thing to forget: it passed for a group the server
# would rewrite to 'details' anyway. ASSET_PANEL_GROUPS is the value that
# actually decides what reaches the client, and panelGroups.spec.js pins it
# against the frontend copy.
RENDERED_GROUPS = set(ASSET_PANEL_GROUPS)
def _panels():
return {panel['id']: panel for panel in EndpointsPlugin().get_asset_panels()}
def test_no_panel_lands_in_a_group_the_frontend_does_not_render():
"""The failure mode that ate cards twice: a group with no tab."""
for panel in EndpointsPlugin().get_asset_panels():
group = panel.get('group', 'details')
assert group in RENDERED_GROUPS, (
f"panel {panel['id']} is in group {group!r}, which no tab renders"
)
def test_tracked_services_and_processes_moved_to_their_own_group():
panels = _panels()
assert panels['endpoints-tracked-services']['group'] == 'serviceprocess'
assert panels['endpoints-tracked-processes']['group'] == 'serviceprocess'
def test_the_service_process_tab_is_not_left_empty():
"""A tab whose panels all moved away reads as broken, not as absent."""
groups = [panel.get('group', 'details')
for panel in EndpointsPlugin().get_asset_panels()]
assert 'serviceprocess' in groups
def test_system_monitors_keeps_panels_of_its_own():
"""The rename is only safe if Monitors still has content after the move."""
groups = [panel.get('group', 'details')
for panel in EndpointsPlugin().get_asset_panels()]
assert 'monitors' in groups
def test_disk_health_stayed_with_the_system_health_panels():
"""Disk health is the box's own health, not something running on it.
It is now a section of the Storage card rather than a panel of its own,
so the group assertion follows it there.
"""
assert 'endpoints-diskhealth' not in _panels()
assert _panels()['endpoints-storage']['group'] == 'monitors'

View File

@@ -11,7 +11,8 @@ from plugins.endpoints.plugin import EndpointsPlugin, CURATED_EVENT_IDS
from plugins.endpoints.eventcatalog import EVENT_CATALOG, CRITICAL_EVENT_IDS
from plugins.endpoints.commands import (
COMMAND_ALLOWLIST, is_allowed_action, elevated_permission_for,
requires_target,
requires_target, minimum_agent_version_for, agent_meets_minimum,
parse_agent_version,
)
@@ -69,12 +70,12 @@ def test_endpoints_asset_panels_are_computer_scoped():
"""Every asset panel targets computer assets only, with an endpoint."""
plugin = EndpointsPlugin()
panels = plugin.get_asset_panels()
assert len(panels) == 7
assert len(panels) == 14
for panel in panels:
assert panel['assettypes'] == ['computer']
assert panel['endpoint'].startswith('/api/endpoints/')
assert '{assetid}' in panel['endpoint']
assert panel['render'] in ('keyvalue', 'list', 'table', 'operatorreports')
assert panel['render'] in ('keyvalue', 'list', 'table', 'tabs', 'operatorreports')
def test_services_and_processes_are_split_into_two_filtered_cards():
@@ -177,6 +178,100 @@ def test_collect_now_is_allowlisted_targetless_and_routine():
assert elevated_permission_for('collect_now') is None
def test_collect_now_survives_a_policy_that_never_mentions_it():
"""Refresh Status must not be narrowed away by an unrelated policy.
A non-empty effective policy skips the permissive fallback in
ALLOWED_ACTIONS_FOR, so any action the policy does not name is intersected
out. collect_now only asks an agent to report state it already has, so
Configuration Policy is not the authority on it - without the exemption
every policied endpoint refused Refresh Status with "Action not permitted
by policy", a denial the operator could not act on because the policy was
never about that action.
"""
from plugins.endpoints.commands import POLICY_EXEMPT_ACTIONS
assert 'collect_now' in POLICY_EXEMPT_ACTIONS
# Exemption from POLICY is not exemption from PERMISSION: it stays gated by
# the base endpoints.command grant, which is what keeps guests out.
assert elevated_permission_for('collect_now') is None
def test_collect_log_survives_a_policy_that_never_mentions_it():
"""Log collection must not be narrowed away by an unrelated policy.
Same intersection trap as collect_now, but with a sharper failure: every
existing policy predates this action and so cannot name it, meaning any
endpoint carrying a policy refused log collection outright - including for
an admin, who reads "requires permission" and goes looking at a grant they
already hold. Logs are also what an operator reaches for when an endpoint
is already misbehaving, so a policy gate here withholds the evidence
exactly when it is wanted.
"""
from plugins.endpoints.commands import POLICY_EXEMPT_ACTIONS
assert 'collect_log' in POLICY_EXEMPT_ACTIONS
# Exemption from POLICY is not exemption from PERMISSION. Unlike
# collect_now this one DOES carry an elevated grant, so the assertion is
# that the grant survived the exemption rather than being loosened by it.
assert elevated_permission_for('collect_log') == 'endpoints.updateapp'
def test_collect_log_declares_the_agent_version_that_implements_it():
"""collect_log names its minimum agent version.
An agent older than the action has no case for it and falls through to its
unknown-action default, answering "Unknown or disallowed action" - which
reads as an authorization refusal and sends the operator to check a grant
they already hold. The floor is what lets the server refuse first with the
actionable answer instead.
"""
assert minimum_agent_version_for('collect_log') == '1.16.0'
# Actions that every shipped agent understands must NOT declare a floor,
# or the guard starts refusing endpoints that are perfectly capable.
assert minimum_agent_version_for('collect_now') is None
assert minimum_agent_version_for('system_restart') is None
def test_agent_version_comparison_survives_real_fleet_version_strings():
"""Version comparison is numeric per part, not lexical.
Two traps, both real in this fleet. Agents report four parts ('1.15.0.0')
while the csproj and binary store carry three ('1.16.0'), so the same build
must compare equal across both spellings. And a lexical compare ranks
'1.9.0.0' ABOVE '1.16.0' because '9' > '1' - which would silently let an
old agent past the guard, the exact failure the guard exists to prevent.
"""
assert parse_agent_version('1.16.0') == parse_agent_version('1.16.0.0')
assert agent_meets_minimum('1.16.0', '1.16.0')
assert agent_meets_minimum('1.16.0.0', '1.16.0')
assert agent_meets_minimum('1.17.0.0', '1.16.0')
assert agent_meets_minimum('2.0.0.0', '1.16.0')
assert not agent_meets_minimum('1.15.0.0', '1.16.0')
assert not agent_meets_minimum('1.11.0.0', '1.16.0')
# The lexical trap.
assert not agent_meets_minimum('1.9.0.0', '1.16.0')
def test_an_unreadable_agent_version_fails_the_guard_closed():
"""An unparseable or absent version does not satisfy a minimum.
Eight endpoints in this fleet report '0.0.0-sample', and a never-checked-in
endpoint reports nothing at all. There is no honest ordering between a
placeholder and a real build, so the guard refuses rather than guessing.
Letting it through would only move the failure to the agent, where the
error message is the misleading one.
"""
assert parse_agent_version('0.0.0-sample') is None
assert not agent_meets_minimum('0.0.0-sample', '1.16.0')
assert not agent_meets_minimum(None, '1.16.0')
assert not agent_meets_minimum('', '1.16.0')
# No declared floor means no opinion: an action every agent implements must
# stay available even to an endpoint whose version we cannot read.
assert agent_meets_minimum('0.0.0-sample', None)
assert agent_meets_minimum(None, None)
def test_run_script_and_update_app_are_allowlisted_delivery_actions():
"""The two software-delivery actions are allowlisted with a targetkind so
the enqueue route accepts them and carries their subject on the wire:
@@ -189,19 +284,36 @@ def test_run_script_and_update_app_are_allowlisted_delivery_actions():
assert COMMAND_ALLOWLIST['update_app']['targetkind'] == 'app'
def test_delivery_actions_require_the_elevated_deploy_permission():
"""run_script and update_app both demand endpoints.deploy ON TOP OF
endpoints.command, so pushing software is a strictly heavier grant than a
routine service/process toggle."""
assert elevated_permission_for('run_script') == 'endpoints.deploy'
assert elevated_permission_for('update_app') == 'endpoints.deploy'
def test_big_four_actions_each_require_their_own_elevated_permission():
"""The four scope-gated actions map to four INDEPENDENT permissions.
They previously shared endpoints.deploy, which meant granting an operator
application updates also granted arbitrary SYSTEM code execution through
run_script. Keeping these distinct is the whole point of the split, so a
regression that re-bundles them must fail here.
"""
assert elevated_permission_for('system_restart') == 'endpoints.restart'
assert elevated_permission_for('run_script') == 'endpoints.runscript'
assert elevated_permission_for('update_app') == 'endpoints.updateapp'
assert elevated_permission_for('scheduledtask_run') == 'endpoints.corptools'
granted = {
elevated_permission_for(action)
for action in ('system_restart', 'run_script', 'update_app',
'scheduledtask_run')
}
assert len(granted) == 4
def test_endpoints_declares_the_deploy_permission():
"""The dedicated software-delivery permission is declared for role seeding."""
def test_endpoints_declares_the_split_delivery_permissions():
"""Each split grant is declared so role seeding can offer it, and the
retired bundle is gone so the role grid stops offering a dead permission."""
plugin = EndpointsPlugin()
permissions = {name for name, *_ in plugin.get_permissions()}
assert 'endpoints.deploy' in permissions
assert 'endpoints.runscript' in permissions
assert 'endpoints.updateapp' in permissions
assert 'endpoints.corptools' in permissions
assert 'endpoints.restart' in permissions
assert 'endpoints.deploy' not in permissions
def test_known_payload_keys_cover_persisted_and_relayed_fields():
@@ -220,18 +332,21 @@ def test_known_payload_keys_cover_persisted_and_relayed_fields():
assert persisted_or_relayed <= KNOWN_PAYLOAD_KEYS
def test_new_hardware_telemetry_keys_are_logged_not_silently_known():
"""Disk/bios/memory inventory is collected at the agent before the server
persists it. Those keys must be OUTSIDE the known set so ingest logs them
rather than dropping them silently."""
def test_hardware_telemetry_keys_are_persisted_not_merely_logged():
"""Disk/bios/memory/processor/osinfo inventory was collected at the agent and
logged as unknown keys for several versions while the shapes were proven
against real endpoint traffic. They have columns now (endpoints migration
0013), so they must be INSIDE the known set - leaving them out would log
every report as carrying unpersisted keys while the ingest quietly stores
them, which is the most misleading combination available."""
from plugins.endpoints.plugin import KNOWN_PAYLOAD_KEYS
for key in ('disks', 'bios', 'memory', 'processor', 'osinfo', 'domain'):
assert key not in KNOWN_PAYLOAD_KEYS
for key in ('disks', 'bios', 'memory', 'processor', 'osinfo', 'domain',
'pendingreboot', 'usercontext'):
assert key in KNOWN_PAYLOAD_KEYS
# 'applications' USED to be in this list. It is persisted now - that is what
# the installed-software work added - so it belongs in the known set, and
# asserting its absence would fail the moment the feature it tests works.
# 'applications' followed this same staging path earlier and is likewise
# persisted, to its own endpointapplications table rather than a state blob.
assert 'applications' in KNOWN_PAYLOAD_KEYS
@@ -287,13 +402,26 @@ def test_fleet_list_route_resolves_without_trailing_slash():
# Dockerfile, the compose files or the Windows deploy pins TZ.
def _in_zone(zonename, call):
"""Run call() with the process in a named zone, then put it back."""
"""Run call() with the process in a named zone, then put it back.
time.tzset() is POSIX-only and absent on Windows, where the process zone
cannot be moved this way at all. Rather than fail three tests permanently on
every Windows checkout - which teaches the developer to ignore a red suite -
the shift is skipped there and the assertions still run in whatever zone the
machine is in. That is weaker locally but not worthless: _parse_dt must
return the same UTC answer in EVERY zone, so the assertion is correct in all
of them, and CI runs on Linux where the real shift happens.
"""
import os
import time
tzset = getattr(time, 'tzset', None)
if tzset is None:
return call()
before = os.environ.get('TZ')
os.environ['TZ'] = zonename
time.tzset()
tzset()
try:
return call()
finally:
@@ -301,7 +429,7 @@ def _in_zone(zonename, call):
os.environ.pop('TZ', None)
else:
os.environ['TZ'] = before
time.tzset()
tzset()
def test_a_utc_timestamp_survives_a_non_utc_server():

View File

@@ -0,0 +1,923 @@
"""Admin break-glass over policy gating, and the holds that make it stick.
Policy gating is right almost always and wrong at 3am. These tests pin the
override's boundaries: it must be explicit, admin-only, reasoned, audited, and
it must NOT become a way to acquire an elevated permission the caller lacks.
The hold half exists because an override on a desired-mode action is a fight
with the reconciler - without suppression the next check-in re-enqueues the
opposite command and the override looks broken.
"""
from datetime import datetime, timedelta
from werkzeug.security import generate_password_hash
from shopdb.core.models import Setting
def _enable_policies(db):
if not Setting.query.filter_by(key='computers_configpolicies_enabled').first():
db.session.add(Setting(key='computers_configpolicies_enabled',
value='true'))
db.session.commit()
def _endpoint(db, hostname='holdbox'):
"""An Asset + Computer pair, the minimum a policy can resolve against."""
from shopdb.api import Asset, AssetType
from plugins.computers.models import Computer
assettype = AssetType.query.filter_by(assettype='computer').first()
if assettype is None:
assettype = AssetType(assettype='computer', pluginname='computers',
tablename='computers')
db.session.add(assettype)
db.session.flush()
asset = Asset(assetnumber=hostname, name=hostname,
assettypeid=assettype.assettypeid, isactive=True)
db.session.add(asset)
db.session.flush()
db.session.add(Computer(assetid=asset.assetid, hostname=hostname))
# The enqueue route 404s without a reported state, so the endpoint has to
# look like it has checked in at least once.
from plugins.endpoints.models import EndpointState
db.session.add(EndpointState(assetid=asset.assetid, hostname=hostname))
db.session.commit()
return asset.assetid
def _login(client, username, password='testpass'):
response = client.post('/api/auth/login',
json={'username': username, 'password': password})
token = response.get_json()['data']['access_token']
return {'Authorization': f'Bearer {token}'}
def _user(db, client, username, permnames, admin=False):
from shopdb.core.models import User, Role, Permission
Permission.seed()
Permission.seed_entries([
('endpoints.restart', 'Remote restart', 'endpoints'),
])
db.session.flush()
role = Role(rolename='admin' if admin else username + 'role')
existing = Role.query.filter_by(rolename=role.rolename).first()
if existing is not None:
role = existing
else:
db.session.add(role)
db.session.flush()
role.permissions = Permission.query.filter(
Permission.name.in_(permnames)).all()
user = User(username=username, email=username + '@test.local',
passwordhash=generate_password_hash('testpass'))
user.roles.append(role)
db.session.add(user)
db.session.commit()
return _login(client, username)
def _deny_everything(db, assetid):
"""A policy that matches this endpoint but permits only an unrelated action,
so the action under test is denied by gating."""
from plugins.computers.models import (ConfigurationPolicy,
ConfigurationPolicyAction,
BusinessPolicy, BusinessPolicyItem)
policy = ConfigurationPolicy(name='Locked down', scopejson=None)
db.session.add(policy)
db.session.flush()
db.session.add(ConfigurationPolicyAction(
configurationpolicyid=policy.configurationpolicyid,
action='mtconnect_status'))
business = BusinessPolicy(name='Fleet')
db.session.add(business)
db.session.flush()
db.session.add(BusinessPolicyItem(
businesspolicyid=business.businesspolicyid,
configurationpolicyid=policy.configurationpolicyid))
db.session.commit()
def _url(assetid):
return f'/api/endpoints/{assetid}/commands'
def test_policy_denial_still_applies_without_an_override(client, db):
"""The default path must be unchanged: no flag, no bypass."""
_enable_policies(db)
assetid = _endpoint(db)
_deny_everything(db, assetid)
headers = _user(db, client, 'plainadmin',
['endpoints.view', 'endpoints.command'], admin=True)
response = client.post(_url(assetid), headers=headers,
json={'action': 'service_status',
'target': 'spooler'})
assert response.status_code == 400, response.get_json()
assert 'not permitted by policy' in \
response.get_json()['data']['error']['message']
def test_a_non_admin_cannot_override_a_policy_denial(client, db):
"""endpoints.command is enough to queue work, never enough to break glass."""
_enable_policies(db)
assetid = _endpoint(db)
_deny_everything(db, assetid)
headers = _user(db, client, 'operator',
['endpoints.view', 'endpoints.command'])
response = client.post(_url(assetid), headers=headers,
json={'action': 'service_status',
'target': 'spooler',
'policyoverride': True,
'overridereason': 'trying it on'})
assert response.status_code == 403, response.get_json()
assert 'admin' in response.get_json()['data']['error']['message']
def test_an_override_without_a_reason_is_refused(client, db):
"""An unexplained break-glass is an unreviewable one."""
_enable_policies(db)
assetid = _endpoint(db)
_deny_everything(db, assetid)
headers = _user(db, client, 'reasonless',
['endpoints.view', 'endpoints.command'], admin=True)
response = client.post(_url(assetid), headers=headers,
json={'action': 'service_status',
'target': 'spooler',
'policyoverride': True})
assert response.status_code == 400, response.get_json()
assert 'overridereason' in response.get_json()['data']['error']['message']
def test_an_admin_override_is_accepted_and_audited(client, db):
"""The whole point, plus the audit trail that justifies allowing it."""
from shopdb.api import AuditLog
_enable_policies(db)
assetid = _endpoint(db)
_deny_everything(db, assetid)
headers = _user(db, client, 'breakglass',
['endpoints.view', 'endpoints.command'], admin=True)
response = client.post(_url(assetid), headers=headers,
json={'action': 'service_status',
'target': 'spooler',
'policyoverride': True,
'overridereason': 'line down, need status'})
assert response.status_code in (200, 201), response.get_json()
entry = AuditLog.query.filter_by(action='policyoverridden').first()
assert entry is not None, 'break-glass must leave an audit record'
def test_a_refused_override_leaves_no_hold_and_no_audit_row(client, db):
"""Validation after the override branch must run before anything is written.
A targetless service action, and a second command while one is in flight,
are both refused. Neither may leave a policyoverridden audit row or a
PolicyActionHold behind, or the record claims a break-glass that never ran.
"""
from shopdb.api import AuditLog
from plugins.computers.models import PolicyActionHold
from plugins.endpoints.models import EndpointCommand
_enable_policies(db)
assetid = _endpoint(db, 'refusedbox')
_deny_everything(db, assetid)
headers = _user(db, client, 'refusedadmin',
['endpoints.view', 'endpoints.command'], admin=True)
missing_target = client.post(_url(assetid), headers=headers,
json={'action': 'service_status',
'policyoverride': True,
'overridereason': 'no target given'})
assert missing_target.status_code == 400, missing_target.get_json()
db.session.add(EndpointCommand(assetid=assetid, action='collect_now',
status=EndpointCommand.STATUS_PENDING))
db.session.commit()
in_flight = client.post(_url(assetid), headers=headers,
json={'action': 'service_status',
'target': 'spooler',
'policyoverride': True,
'overridereason': 'queue is busy'})
assert in_flight.status_code == 409, in_flight.get_json()
db.session.expire_all()
assert AuditLog.query.filter_by(action='policyoverridden').count() == 0
assert PolicyActionHold.query.filter_by(assetid=assetid).count() == 0
def test_override_is_checked_after_the_elevated_permission_gate(client, db):
"""The override widens WHERE an action may run, never WHICH actions the
caller may use.
A human admin holds every permission via the admin bypass, so this is moot
for people and cannot be shown with an admin fixture. It matters for TOKENS:
authz suspends the admin bypass for a scoped personal token, so a token
scoped to endpoints.command is refused at the elevated gate and never
reaches the override branch at all - even though its owner is an admin, and
even though it asks for the override explicitly.
"""
from shopdb.core.models import Permission
_enable_policies(db)
assetid = _endpoint(db, 'tokenbox')
_deny_everything(db, assetid)
Permission.seed_entries([
('apitokens.create', 'Mint tokens', 'core'),
])
db.session.commit()
ownerheaders = _user(db, client, 'tokenadmin',
['endpoints.view', 'endpoints.command',
'apitokens.create'], admin=True)
minted = client.post('/api/apitokens', headers=ownerheaders,
json={'name': 'agent',
'scopes': ['endpoints.command']})
secret = minted.get_json()['data']['secret']
headers = {'Authorization': f'Bearer {secret}'}
response = client.post(_url(assetid), headers=headers,
json={'action': 'system_restart',
'policyoverride': True,
'overridereason': 'leaked token tries it on'})
assert response.status_code == 403, response.get_json()
def test_a_hold_suppresses_desired_state_reconciliation(client, db):
"""Without this the reconciler undoes the override on the next check-in."""
from plugins.computers.models import PolicyActionHold
from plugins.computers.services.policyresolver import effective_policy_for
_enable_policies(db)
assetid = _endpoint(db, 'desiredbox')
from plugins.computers.models import (ConfigurationPolicy,
ConfigurationPolicyAction,
BusinessPolicy, BusinessPolicyItem)
policy = ConfigurationPolicy(name='Keep it running', scopejson=None)
db.session.add(policy)
db.session.flush()
db.session.add(ConfigurationPolicyAction(
configurationpolicyid=policy.configurationpolicyid,
action='service_start', mode='desired',
desiredstate={'running': True}))
business = BusinessPolicy(name='Desired fleet')
db.session.add(business)
db.session.flush()
db.session.add(BusinessPolicyItem(
businesspolicyid=business.businesspolicyid,
configurationpolicyid=policy.configurationpolicyid))
db.session.commit()
assert 'service_start' in effective_policy_for(assetid).desired_actions()
db.session.add(PolicyActionHold(
assetid=assetid, action='service_start',
expiresat=datetime.utcnow() + timedelta(minutes=60),
reason='maintenance window'))
db.session.commit()
effective = effective_policy_for(assetid)
assert 'service_start' not in effective.desired_actions()
# A hold suspends drift correction; it does not revoke the permission to
# run the action by hand.
assert 'service_start' in effective.allowed_actions()
def test_an_expired_hold_stops_suppressing(client, db):
"""Policy resumes on its own. Nobody has to remember to clear a hold, and a
stalled sweeper cannot silently disable reconciliation forever."""
from plugins.computers.models import PolicyActionHold
from plugins.computers.services.policyresolver import effective_policy_for
_enable_policies(db)
assetid = _endpoint(db, 'expiredbox')
from plugins.computers.models import (ConfigurationPolicy,
ConfigurationPolicyAction,
BusinessPolicy, BusinessPolicyItem)
policy = ConfigurationPolicy(name='Still running', scopejson=None)
db.session.add(policy)
db.session.flush()
db.session.add(ConfigurationPolicyAction(
configurationpolicyid=policy.configurationpolicyid,
action='service_start', mode='desired',
desiredstate={'running': True}))
business = BusinessPolicy(name='Expiry fleet')
db.session.add(business)
db.session.flush()
db.session.add(BusinessPolicyItem(
businesspolicyid=business.businesspolicyid,
configurationpolicyid=policy.configurationpolicyid))
db.session.add(PolicyActionHold(
assetid=assetid, action='service_start',
expiresat=datetime.utcnow() - timedelta(minutes=1),
reason='window that already closed'))
db.session.commit()
assert 'service_start' in effective_policy_for(assetid).desired_actions()
def test_active_holds_are_visible_to_a_non_admin_viewer(client, db):
"""A hold explains drift, so it must not be admin-only reading.
An operator who can see the machine but not the hold is left debugging
suppressed reconciliation as if it were a fault.
"""
from plugins.computers.models import PolicyActionHold
assetid = _endpoint(db)
db.session.add(PolicyActionHold(
assetid=assetid, action='service_stop',
expiresat=datetime.utcnow() + timedelta(minutes=60),
createdbyusername='nightadmin',
reason='vendor on site'))
db.session.commit()
headers = _user(db, client, 'viewer', ['endpoints.view'])
response = client.get(f'/api/endpoints/{assetid}/policyholds',
headers=headers)
assert response.status_code == 200, response.get_json()
holds = response.get_json()['data']
assert len(holds) == 1
assert holds[0]['action'] == 'service_stop'
assert holds[0]['createdbyusername'] == 'nightadmin'
assert holds[0]['reason'] == 'vendor on site'
def test_expired_holds_are_not_reported_as_active(client, db):
"""Expiry is evaluated on read, matching the resolver.
If the route reported expired rows the banner would claim enforcement is
paused long after it resumed - worse than showing nothing.
"""
from plugins.computers.models import PolicyActionHold
assetid = _endpoint(db)
db.session.add(PolicyActionHold(
assetid=assetid, action='service_stop',
expiresat=datetime.utcnow() - timedelta(minutes=1),
createdbyusername='nightadmin',
reason='window that already closed'))
db.session.commit()
headers = _user(db, client, 'viewer2', ['endpoints.view'])
response = client.get(f'/api/endpoints/{assetid}/policyholds',
headers=headers)
assert response.status_code == 200, response.get_json()
assert response.get_json()['data'] == []
def _desired_policy(db, assetid, action='service_start'):
"""A policy putting one action under desired-state enforcement."""
from plugins.computers.models import (ConfigurationPolicy,
ConfigurationPolicyAction,
BusinessPolicy, BusinessPolicyItem)
policy = ConfigurationPolicy(name=f'Desired {action}', scopejson=None)
db.session.add(policy)
db.session.flush()
db.session.add(ConfigurationPolicyAction(
configurationpolicyid=policy.configurationpolicyid,
action=action, mode='desired', desiredstate={'running': True}))
business = BusinessPolicy(name=f'Fleet {action}')
db.session.add(business)
db.session.flush()
db.session.add(BusinessPolicyItem(
businesspolicyid=business.businesspolicyid,
configurationpolicyid=policy.configurationpolicyid))
db.session.commit()
def test_an_admin_can_place_a_hold_directly(client, db):
"""The hold must be reachable deliberately, not only by tripping a denial."""
_enable_policies(db)
assetid = _endpoint(db)
_desired_policy(db, assetid)
headers = _user(db, client, 'holdadmin',
['endpoints.view', 'endpoints.command'], admin=True)
response = client.post(f'/api/endpoints/{assetid}/policyholds',
headers=headers,
json={'action': 'service_start',
'reason': 'vendor on site',
'minutes': 60})
assert response.status_code == 200, response.get_json()
holds = response.get_json()['data']
assert [h['action'] for h in holds] == ['service_start']
# The point of a hold: reconciliation stops asking for this action.
from plugins.computers.services.policyresolver import effective_policy_for
assert 'service_start' not in effective_policy_for(assetid).desired_actions()
def test_a_non_admin_cannot_place_a_hold(client, db):
"""endpoints.command queues work; it does not switch off enforcement."""
_enable_policies(db)
assetid = _endpoint(db)
_desired_policy(db, assetid)
headers = _user(db, client, 'holdoperator',
['endpoints.view', 'endpoints.command'])
response = client.post(f'/api/endpoints/{assetid}/policyholds',
headers=headers,
json={'action': 'service_start',
'reason': 'trying it on'})
assert response.status_code == 403, response.get_json()
def test_a_hold_requires_a_reason(client, db):
"""An unexplained hold is an unattributable outage waiting to happen."""
_enable_policies(db)
assetid = _endpoint(db)
_desired_policy(db, assetid)
headers = _user(db, client, 'holdadmin2',
['endpoints.view', 'endpoints.command'], admin=True)
response = client.post(f'/api/endpoints/{assetid}/policyholds',
headers=headers,
json={'action': 'service_start', 'reason': ' '})
assert response.status_code == 400, response.get_json()
def test_holding_a_non_desired_action_is_refused(client, db):
"""A hold on a gate-mode action would suppress nothing.
Accepting it would report a hold that has no effect, which is worse than
refusing outright.
"""
_enable_policies(db)
assetid = _endpoint(db)
_deny_everything(db, assetid)
headers = _user(db, client, 'holdadmin3',
['endpoints.view', 'endpoints.command'], admin=True)
response = client.post(f'/api/endpoints/{assetid}/policyholds',
headers=headers,
json={'action': 'mtconnect_status',
'reason': 'not under enforcement'})
assert response.status_code == 400, response.get_json()
assert 'nothing to hold' in \
response.get_json()['data']['error']['message']
def test_an_admin_can_release_a_hold_early(client, db):
"""Release resumes reconciliation without waiting for expiry."""
_enable_policies(db)
assetid = _endpoint(db)
_desired_policy(db, assetid)
headers = _user(db, client, 'holdadmin4',
['endpoints.view', 'endpoints.command'], admin=True)
client.post(f'/api/endpoints/{assetid}/policyholds', headers=headers,
json={'action': 'service_start', 'reason': 'swapping a disk'})
from plugins.computers.services.policyresolver import effective_policy_for
assert 'service_start' not in effective_policy_for(assetid).desired_actions()
response = client.delete(
f'/api/endpoints/{assetid}/policyholds/service_start', headers=headers)
assert response.status_code == 200, response.get_json()
assert response.get_json()['data'] == []
assert 'service_start' in effective_policy_for(assetid).desired_actions()
def test_releasing_a_hold_keeps_the_audit_row(client, db):
"""Release soft-deletes: the hold IS the record of what was suppressed."""
from plugins.computers.models import PolicyActionHold
_enable_policies(db)
assetid = _endpoint(db)
_desired_policy(db, assetid)
headers = _user(db, client, 'holdadmin5',
['endpoints.view', 'endpoints.command'], admin=True)
client.post(f'/api/endpoints/{assetid}/policyholds', headers=headers,
json={'action': 'service_start', 'reason': 'keep this record'})
client.delete(f'/api/endpoints/{assetid}/policyholds/service_start',
headers=headers)
row = PolicyActionHold.query.filter_by(assetid=assetid,
action='service_start').first()
assert row is not None
assert row.isactive is False
assert row.reason == 'keep this record'
def test_holdable_actions_lists_desired_actions_with_hold_state(client, db):
"""The picker must keep showing an action after it is held.
Built from desired_actions() it would vanish on being held, making the
current state unreadable.
"""
_enable_policies(db)
assetid = _endpoint(db)
_desired_policy(db, assetid)
headers = _user(db, client, 'holdadmin6',
['endpoints.view', 'endpoints.command'], admin=True)
response = client.get(f'/api/endpoints/{assetid}/holdableactions',
headers=headers)
assert response.status_code == 200, response.get_json()
assert response.get_json()['data'] == [
{'action': 'service_start', 'held': False}]
client.post(f'/api/endpoints/{assetid}/policyholds', headers=headers,
json={'action': 'service_start', 'reason': 'still listed'})
response = client.get(f'/api/endpoints/{assetid}/holdableactions',
headers=headers)
assert response.get_json()['data'] == [
{'action': 'service_start', 'held': True}]
def _lock_url(assetid):
return f'/api/endpoints/{assetid}/lock'
def test_a_lock_refuses_a_non_admin_command(client, db):
"""The whole point: a locked endpoint stops other people acting on it.
No policy is enabled here, so nothing but the lock can be doing the
refusing - otherwise this test would pass for the wrong reason.
"""
assetid = _endpoint(db)
adminheaders = _user(db, client, 'lockadmin',
['endpoints.view', 'endpoints.command'], admin=True)
client.post(_lock_url(assetid), headers=adminheaders,
json={'reason': 'mid repair'})
headers = _user(db, client, 'lockoperator',
['endpoints.view', 'endpoints.command'])
response = client.post(_url(assetid), headers=headers,
json={'action': 'service_status',
'target': 'spooler'})
assert response.status_code == 403, response.get_json()
def test_a_lock_refusal_names_the_owner_and_reason(client, db):
"""A bare "forbidden" sends an operator hunting through policy for a cause
that is not there. The refusal has to say who to ask and why."""
assetid = _endpoint(db)
adminheaders = _user(db, client, 'lockadmin2',
['endpoints.view', 'endpoints.command'], admin=True)
client.post(_lock_url(assetid), headers=adminheaders,
json={'reason': 'validated part running'})
headers = _user(db, client, 'lockoperator2',
['endpoints.view', 'endpoints.command'])
response = client.post(_url(assetid), headers=headers,
json={'action': 'service_status',
'target': 'spooler'})
message = response.get_json()['data']['error']['message']
assert 'lockadmin2' in message
assert 'validated part running' in message
def test_an_admin_acts_through_a_lock_without_an_override(client, db):
"""The lock gates WHO may act, not WHICH actions exist, so there is no
policy decision for an admin to break past. Requiring break-glass here would
mean an admin overriding their own quarantine to do the repair it was set
for."""
assetid = _endpoint(db)
headers = _user(db, client, 'lockadmin3',
['endpoints.view', 'endpoints.command'], admin=True)
client.post(_lock_url(assetid), headers=headers,
json={'reason': 'mine to work on'})
response = client.post(_url(assetid), headers=headers,
json={'action': 'service_status',
'target': 'spooler'})
assert response.status_code == 201, response.get_json()
def test_a_lock_cannot_be_broken_by_a_policy_override(client, db):
"""The two gates are independent and the lock is the outer one.
policyoverride exists to push past a POLICY denial. If it also dissolved a
lock, any non-admin who learned the flag could act on a quarantined machine,
and the lock would be advisory.
"""
_enable_policies(db)
assetid = _endpoint(db)
_deny_everything(db, assetid)
adminheaders = _user(db, client, 'lockadmin4',
['endpoints.view', 'endpoints.command'], admin=True)
client.post(_lock_url(assetid), headers=adminheaders,
json={'reason': 'do not touch'})
headers = _user(db, client, 'lockoperator4',
['endpoints.view', 'endpoints.command'])
response = client.post(_url(assetid), headers=headers,
json={'action': 'service_status',
'target': 'spooler',
'policyoverride': True,
'overridereason': 'trying to slip past'})
assert response.status_code == 403, response.get_json()
assert 'locked' in response.get_json()['data']['error']['message'].lower()
def test_a_non_admin_cannot_place_a_lock(client, db):
assetid = _endpoint(db)
headers = _user(db, client, 'lockoperator5',
['endpoints.view', 'endpoints.command'])
response = client.post(_lock_url(assetid), headers=headers,
json={'reason': 'not my call'})
assert response.status_code == 403, response.get_json()
def test_a_lock_requires_a_reason(client, db):
"""The reason is the message a blocked operator reads, not paperwork."""
assetid = _endpoint(db)
headers = _user(db, client, 'lockadmin6',
['endpoints.view', 'endpoints.command'], admin=True)
response = client.post(_lock_url(assetid), headers=headers,
json={'reason': ' '})
assert response.status_code == 400, response.get_json()
def test_locking_twice_does_not_stack_a_second_lock(client, db):
"""Two active locks on one endpoint is not a state the table should hold,
and the caller who locked a locked machine got what they asked for."""
assetid = _endpoint(db)
headers = _user(db, client, 'lockadmin7',
['endpoints.view', 'endpoints.command'], admin=True)
first = client.post(_lock_url(assetid), headers=headers,
json={'reason': 'first'})
second = client.post(_lock_url(assetid), headers=headers,
json={'reason': 'second'})
assert second.status_code == 200, second.get_json()
# The original stands - the second call did not silently replace the reason
# the first admin recorded.
assert second.get_json()['data']['endpointlockid'] == \
first.get_json()['data']['endpointlockid']
assert second.get_json()['data']['reason'] == 'first'
def test_releasing_a_lock_restores_non_admin_commands(client, db):
assetid = _endpoint(db)
adminheaders = _user(db, client, 'lockadmin8',
['endpoints.view', 'endpoints.command'], admin=True)
client.post(_lock_url(assetid), headers=adminheaders,
json={'reason': 'briefly'})
client.delete(_lock_url(assetid), headers=adminheaders)
headers = _user(db, client, 'lockoperator8',
['endpoints.view', 'endpoints.command'])
response = client.post(_url(assetid), headers=headers,
json={'action': 'service_status',
'target': 'spooler'})
assert response.status_code == 201, response.get_json()
def test_releasing_a_lock_keeps_the_row_as_history(client, db):
"""Released by stamping releasedat, not by deleting. The record of who
quarantined a machine is most wanted after the lock is gone and no longer
there to explain itself."""
from plugins.endpoints.models import EndpointLock
assetid = _endpoint(db)
headers = _user(db, client, 'lockadmin9',
['endpoints.view', 'endpoints.command'], admin=True)
client.post(_lock_url(assetid), headers=headers,
json={'reason': 'keep this record'})
client.delete(_lock_url(assetid), headers=headers)
row = EndpointLock.query.filter_by(assetid=assetid).first()
assert row is not None
assert row.reason == 'keep this record'
assert row.lockedbyusername == 'lockadmin9'
assert row.releasedat is not None
assert row.releasedbyusername == 'lockadmin9'
def test_releasing_an_unlocked_endpoint_is_a_404(client, db):
"""So a caller can tell "unlocked it" from "there was nothing to unlock"."""
assetid = _endpoint(db)
headers = _user(db, client, 'lockadmin10',
['endpoints.view', 'endpoints.command'], admin=True)
response = client.delete(_lock_url(assetid), headers=headers)
assert response.status_code == 404, response.get_json()
def test_a_non_admin_can_read_the_lock(client, db):
"""Read-gated, not admin-gated. An operator who cannot see the lock is left
with a bare refusal and files a ticket about a machine that is working."""
assetid = _endpoint(db)
adminheaders = _user(db, client, 'lockadmin11',
['endpoints.view', 'endpoints.command'], admin=True)
client.post(_lock_url(assetid), headers=adminheaders,
json={'reason': 'visible to all'})
headers = _user(db, client, 'lockoperator11', ['endpoints.view'])
response = client.get(_lock_url(assetid), headers=headers)
assert response.status_code == 200, response.get_json()
assert response.get_json()['data']['reason'] == 'visible to all'
def test_an_unlocked_endpoint_reports_no_lock(client, db):
assetid = _endpoint(db)
headers = _user(db, client, 'lockoperator12', ['endpoints.view'])
response = client.get(_lock_url(assetid), headers=headers)
assert response.status_code == 200, response.get_json()
# success_response(None) omits data entirely rather than emitting null, so
# "no lock" is an absent key. The frontend reads it as response.data?.data
# || null, which lands on the same answer either way.
assert response.get_json().get('data') is None
def test_a_lock_does_not_suppress_policy_reconciliation(client, db):
"""The two axes stay separate. A lock gates callers; it must not reach into
the resolver and start suppressing desired-state actions, which is the hold's
job and carries an expiry a lock does not have."""
from plugins.computers.services.policyresolver import effective_policy_for
_enable_policies(db)
assetid = _endpoint(db)
_desired_policy(db, assetid)
headers = _user(db, client, 'lockadmin13',
['endpoints.view', 'endpoints.command'], admin=True)
client.post(_lock_url(assetid), headers=headers,
json={'reason': 'locked but still reconciled'})
assert 'service_start' in effective_policy_for(assetid).desired_actions()
def test_an_admin_can_hold_several_actions_at_once(client, db):
"""One dialog submission covers the whole job, not one action per trip.
An admin about to work on a machine usually needs several actions pinned,
and making them repeat the dialog invites stopping halfway.
"""
from plugins.computers.services.policyresolver import effective_policy_for
_enable_policies(db)
assetid = _endpoint(db)
_desired_policy(db, assetid, action='service_start')
_desired_policy(db, assetid, action='service_stop')
headers = _user(db, client, 'multiadmin1',
['endpoints.view', 'endpoints.command'], admin=True)
response = client.post(f'/api/endpoints/{assetid}/policyholds',
headers=headers,
json={'actions': ['service_start', 'service_stop'],
'reason': 'stripping the enclosure',
'minutes': 60})
assert response.status_code == 200, response.get_json()
held = {h['action'] for h in response.get_json()['data']}
assert held == {'service_start', 'service_stop'}
desired = effective_policy_for(assetid).desired_actions()
assert 'service_start' not in desired
assert 'service_stop' not in desired
def test_a_multi_action_hold_is_all_or_nothing(client, db):
"""One bad name rejects the request whole.
Partial success is the wrong failure mode on a break-glass control: the
admin walks away believing the machine is pinned, and the reconciler undoes
the work covered by the holds that never landed.
"""
from plugins.computers.models import PolicyActionHold
_enable_policies(db)
assetid = _endpoint(db)
_desired_policy(db, assetid, action='service_start')
headers = _user(db, client, 'multiadmin2',
['endpoints.view', 'endpoints.command'], admin=True)
response = client.post(
f'/api/endpoints/{assetid}/policyholds',
headers=headers,
# service_start is holdable; mtconnect_status is not under enforcement.
json={'actions': ['service_start', 'mtconnect_status'],
'reason': 'half of this is invalid'})
assert response.status_code == 400, response.get_json()
# The valid half must NOT have been written on the way to the refusal.
assert PolicyActionHold.query.filter_by(assetid=assetid).count() == 0
def test_a_zero_minute_hold_never_expires(client, db):
"""0 means indefinite: it outlives any window a dated hold would have."""
from plugins.computers.models import PolicyActionHold
from plugins.computers.services.policyresolver import effective_policy_for
_enable_policies(db)
assetid = _endpoint(db)
_desired_policy(db, assetid)
headers = _user(db, client, 'indefadmin1',
['endpoints.view', 'endpoints.command'], admin=True)
response = client.post(f'/api/endpoints/{assetid}/policyholds',
headers=headers,
json={'actions': ['service_start'],
'reason': 'awaiting a replacement part',
'minutes': 0})
assert response.status_code == 200, response.get_json()
row = PolicyActionHold.query.filter_by(assetid=assetid,
action='service_start').first()
assert row.expiresat is None
# Reported active and honoured by the resolver despite having no expiry.
assert response.get_json()['data'][0]['expiresat'] is None
assert 'service_start' not in effective_policy_for(assetid).desired_actions()
def test_an_indefinite_hold_can_still_be_released(client, db):
"""The one hold that never self-clears must be clearable by hand.
Without a null branch in the release query this would be the only kind of
hold the release button could not touch - permanently stuck.
"""
from plugins.computers.services.policyresolver import effective_policy_for
_enable_policies(db)
assetid = _endpoint(db)
_desired_policy(db, assetid)
headers = _user(db, client, 'indefadmin2',
['endpoints.view', 'endpoints.command'], admin=True)
client.post(f'/api/endpoints/{assetid}/policyholds', headers=headers,
json={'actions': ['service_start'], 'reason': 'indefinite',
'minutes': 0})
response = client.delete(
f'/api/endpoints/{assetid}/policyholds/service_start', headers=headers)
assert response.status_code == 200, response.get_json()
assert 'service_start' in effective_policy_for(assetid).desired_actions()
def test_a_negative_hold_window_is_refused(client, db):
"""A negative window places an already-expired hold.
That reads as success in the UI while suppressing nothing - the silent
failure the holdable-action check exists to prevent.
"""
_enable_policies(db)
assetid = _endpoint(db)
_desired_policy(db, assetid)
headers = _user(db, client, 'indefadmin3',
['endpoints.view', 'endpoints.command'], admin=True)
response = client.post(f'/api/endpoints/{assetid}/policyholds',
headers=headers,
json={'actions': ['service_start'],
'reason': 'backwards', 'minutes': -30})
assert response.status_code == 400, response.get_json()
def test_re_holding_an_active_action_does_not_stack_rows(client, db):
"""Idempotent, matching the endpoint lock.
A duplicate row would have to be released once per hidden copy before
reconciliation actually resumed.
"""
from plugins.computers.models import PolicyActionHold
from plugins.computers.services.policyresolver import effective_policy_for
_enable_policies(db)
assetid = _endpoint(db)
_desired_policy(db, assetid)
headers = _user(db, client, 'dupeadmin1',
['endpoints.view', 'endpoints.command'], admin=True)
for _ in range(2):
client.post(f'/api/endpoints/{assetid}/policyholds', headers=headers,
json={'actions': ['service_start'], 'reason': 'twice'})
assert PolicyActionHold.query.filter_by(assetid=assetid,
action='service_start').count() == 1
# One release is therefore enough to resume reconciliation.
client.delete(f'/api/endpoints/{assetid}/policyholds/service_start',
headers=headers)
assert 'service_start' in effective_policy_for(assetid).desired_actions()
def test_a_single_action_hold_payload_still_works(client, db):
"""The pre-list payload shape stays accepted so older callers keep working."""
_enable_policies(db)
assetid = _endpoint(db)
_desired_policy(db, assetid)
headers = _user(db, client, 'legacyadmin1',
['endpoints.view', 'endpoints.command'], admin=True)
response = client.post(f'/api/endpoints/{assetid}/policyholds',
headers=headers,
json={'action': 'service_start',
'reason': 'legacy payload'})
assert response.status_code == 200, response.get_json()
assert [h['action'] for h in response.get_json()['data']] == \
['service_start']

View File

@@ -0,0 +1,200 @@
"""The Run Scripts tab's history feed.
A successful script produces a green badge and a one-line message; nothing in
that tells an operator whether the script did any work. These tests pin the
contract the tab relies on: only script runs, newest first, capped, and
carrying the agent's structured detail so the exit code and run context are
visible without opening the database.
"""
import json
from werkzeug.security import generate_password_hash
def _endpoint(db, hostname='scriptbox'):
"""An Asset + Computer pair with a reported state, as the routes expect."""
from shopdb.api import Asset, AssetType
from plugins.computers.models import Computer
from plugins.endpoints.models import EndpointState
assettype = AssetType.query.filter_by(assettype='computer').first()
if assettype is None:
assettype = AssetType(assettype='computer', pluginname='computers',
tablename='computers')
db.session.add(assettype)
db.session.flush()
asset = Asset(assetnumber=hostname, name=hostname,
assettypeid=assettype.assettypeid, isactive=True)
db.session.add(asset)
db.session.flush()
db.session.add(Computer(assetid=asset.assetid, hostname=hostname))
db.session.add(EndpointState(assetid=asset.assetid, hostname=hostname))
db.session.commit()
return asset.assetid
def _viewer(db, client, username='scriptviewer'):
from shopdb.core.models import User, Role, Permission
Permission.seed()
db.session.flush()
role = Role.query.filter_by(rolename='scriptviewerrole').first()
if role is None:
role = Role(rolename='scriptviewerrole')
db.session.add(role)
db.session.flush()
role.permissions = Permission.query.filter(
Permission.name.in_(['endpoints.view'])).all()
user = User(username=username, email=username + '@test.local',
passwordhash=generate_password_hash('testpass'))
user.roles.append(role)
db.session.add(user)
db.session.commit()
response = client.post('/api/auth/login',
json={'username': username, 'password': 'testpass'})
token = response.get_json()['data']['access_token']
return {'Authorization': f'Bearer {token}'}
def _script(db, name='test-open-notepad'):
from shopdb.core.models import Script
script = Script(scriptname=name, runcontext='system')
db.session.add(script)
db.session.commit()
return script.scriptid
def _command(db, assetid, target, createdat, action='run_script',
status='succeeded', resultjson=None, resultmessage=None):
from plugins.endpoints.models import EndpointCommand
command = EndpointCommand(assetid=assetid, action=action, target=target,
status=status, resultmessage=resultmessage,
resultjson=resultjson, createdat=createdat)
db.session.add(command)
db.session.commit()
return command.endpointcommandid
def test_only_script_runs_are_returned(app, client, db):
"""A restart sitting in the same feed is not a script run.
The tab asks a narrower question than the activity panel, so filtering has
to happen server-side; a mixed feed sliced client-side would quietly show
fewer than five script runs whenever other commands interleave.
"""
from datetime import datetime
with app.app_context():
assetid = _endpoint(db)
scriptid = _script(db)
headers = _viewer(db, client)
_command(db, assetid, f'script:{scriptid}', datetime(2026, 1, 1, 10, 0))
_command(db, assetid, 'restart', datetime(2026, 1, 1, 11, 0),
action='restart')
response = client.get(f'/api/endpoints/{assetid}/scriptruns',
headers=headers)
rows = response.get_json()['data']
assert response.status_code == 200
assert [row['action'] for row in rows] == ['run_script']
def test_newest_first_and_capped_at_five(app, client, db):
"""Six runs, five rows, most recent at the top."""
from datetime import datetime
with app.app_context():
assetid = _endpoint(db)
scriptid = _script(db)
headers = _viewer(db, client)
for hour in range(6):
_command(db, assetid, f'script:{scriptid}',
datetime(2026, 1, 1, 10 + hour, 0))
response = client.get(f'/api/endpoints/{assetid}/scriptruns',
headers=headers)
rows = response.get_json()['data']
assert len(rows) == 5
stamps = [row['createdat'] for row in rows]
assert stamps == sorted(stamps, reverse=True)
def test_agent_detail_is_exposed(app, client, db):
"""The exit code and run context are the evidence behind a green badge."""
from datetime import datetime
with app.app_context():
assetid = _endpoint(db)
scriptid = _script(db)
headers = _viewer(db, client)
_command(db, assetid, f'script:{scriptid}', datetime(2026, 1, 1, 10, 0),
resultmessage='Script completed (system context)',
resultjson=json.dumps({'kind': 'script', 'exitcode': 0,
'runcontext': 'system'}))
response = client.get(f'/api/endpoints/{assetid}/scriptruns',
headers=headers)
row = response.get_json()['data'][0]
assert row['resultdetail']['exitcode'] == 0
assert row['resultdetail']['runcontext'] == 'system'
def test_the_script_name_is_resolved(app, client, db):
"""'script:5' is the wire target, not what the operator picked."""
from datetime import datetime
with app.app_context():
assetid = _endpoint(db)
scriptid = _script(db, name='rebuild-shopfloor-user')
headers = _viewer(db, client)
_command(db, assetid, f'script:{scriptid}', datetime(2026, 1, 1, 10, 0))
response = client.get(f'/api/endpoints/{assetid}/scriptruns',
headers=headers)
row = response.get_json()['data'][0]
assert row['scriptname'] == 'rebuild-shopfloor-user'
def test_malformed_detail_does_not_break_the_list(app, client, db):
"""resultjson is agent-supplied text and can arrive truncated.
A diagnostics panel failing to parse must degrade to "no detail", not take
down the history that surrounds it.
"""
from datetime import datetime
with app.app_context():
assetid = _endpoint(db)
scriptid = _script(db)
headers = _viewer(db, client)
_command(db, assetid, f'script:{scriptid}', datetime(2026, 1, 1, 10, 0),
resultjson='{"kind": "script", "exitcode":')
response = client.get(f'/api/endpoints/{assetid}/scriptruns',
headers=headers)
assert response.status_code == 200
assert response.get_json()['data'][0]['resultdetail'] is None
def test_view_permission_is_required(app, client, db):
"""History reveals what has been run on a machine, so it is gated."""
from datetime import datetime
with app.app_context():
assetid = _endpoint(db)
scriptid = _script(db)
_command(db, assetid, f'script:{scriptid}', datetime(2026, 1, 1, 10, 0))
response = client.get(f'/api/endpoints/{assetid}/scriptruns')
assert response.status_code in (401, 403)

View File

@@ -0,0 +1,157 @@
"""Server TLS certificate upload and generate routes.
The new pair must be parsed, and the key proven to match the certificate,
BEFORE the existing files are backed up or replaced: a bad upload must leave
nginx's current certificate exactly where it was. cn and san must be plain
hostname/IP syntax; anything else (newlines, '=', brackets) is refused.
"""
import io
from datetime import datetime, timedelta, timezone
import pytest
from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.x509.oid import NameOID
from plugins.endpoints.api import routes as endpoint_routes
UPLOAD_URL = '/api/endpoints/settings/server-cert/upload'
GENERATE_URL = '/api/endpoints/settings/server-cert/generate'
@pytest.fixture(autouse=True)
def cert_dir(tmp_path, monkeypatch):
directory = tmp_path / 'certs'
directory.mkdir()
monkeypatch.setattr(endpoint_routes, '_server_cert_dir', lambda: directory)
monkeypatch.setattr(endpoint_routes, '_restart_nginx', lambda: None)
return directory
def _pair(cn='old.example.test'):
key = ec.generate_private_key(ec.SECP256R1())
name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, cn)])
now = datetime.now(timezone.utc)
certificate = (x509.CertificateBuilder()
.subject_name(name).issuer_name(name)
.public_key(key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(now - timedelta(days=1))
.not_valid_after(now + timedelta(days=30))
.sign(key, hashes.SHA256()))
cert_pem = certificate.public_bytes(serialization.Encoding.PEM)
key_pem = key.private_bytes(serialization.Encoding.PEM,
serialization.PrivateFormat.PKCS8,
serialization.NoEncryption())
return cert_pem, key_pem
def _seed_existing(cert_dir):
cert_pem, key_pem = _pair('existing.example.test')
(cert_dir / 'server.crt').write_bytes(cert_pem)
(cert_dir / 'server.key').write_bytes(key_pem)
return cert_pem, key_pem
def _upload(client, headers, cert_bytes, key_bytes):
return client.post(UPLOAD_URL, headers=headers,
content_type='multipart/form-data',
data={'cert': (io.BytesIO(cert_bytes), 'server.crt'),
'key': (io.BytesIO(key_bytes), 'server.key')})
def test_a_matching_pair_replaces_and_backs_up(client, db, auth_headers, cert_dir):
from shopdb.api import AuditLog
old_cert, old_key = _seed_existing(cert_dir)
new_cert, new_key = _pair('new.example.test')
response = _upload(client, auth_headers, new_cert, new_key)
assert response.status_code == 200, response.get_json()
assert (cert_dir / 'server.crt').read_bytes() == new_cert
assert (cert_dir / 'server.key').read_bytes() == new_key
backups = sorted(p.name for p in cert_dir.iterdir() if '.backup.' in p.name)
assert len(backups) == 2
assert not [p for p in cert_dir.iterdir() if p.name.endswith('.tmp')]
assert AuditLog.query.filter_by(
action='endpoints.server_cert.uploaded').count() == 1
def test_a_mismatched_key_touches_nothing(client, db, auth_headers, cert_dir):
old_cert, old_key = _seed_existing(cert_dir)
new_cert, _new_key = _pair('new.example.test')
_other_cert, other_key = _pair('other.example.test')
response = _upload(client, auth_headers, new_cert, other_key)
assert response.status_code == 400
assert 'does not match' in response.get_json()['data']['error']['message']
assert (cert_dir / 'server.crt').read_bytes() == old_cert
assert (cert_dir / 'server.key').read_bytes() == old_key
assert sorted(p.name for p in cert_dir.iterdir()) == ['server.crt', 'server.key']
def test_garbage_certificate_touches_nothing(client, db, auth_headers, cert_dir):
old_cert, _old_key = _seed_existing(cert_dir)
_cert, key = _pair()
garbage = b'-----BEGIN CERTIFICATE-----\nnope\n-----END CERTIFICATE-----\n'
response = _upload(client, auth_headers, garbage, key)
assert response.status_code == 400
assert (cert_dir / 'server.crt').read_bytes() == old_cert
assert len(list(cert_dir.iterdir())) == 2
@pytest.mark.parametrize('cn', [
'host\nprompt = yes',
'bad host',
'a=b',
'[alt_names]',
'a' * 65,
])
def test_generate_refuses_unsafe_cn(client, db, auth_headers, cert_dir, cn):
_seed_existing(cert_dir)
response = client.post(GENERATE_URL, headers=auth_headers,
json={'cn': cn, 'days': 30})
assert response.status_code == 400
assert len(list(cert_dir.iterdir())) == 2
@pytest.mark.parametrize('san', [
'DNS:ok.example.test,DNS:bad\nname',
'IP:999.1.1.1',
'URI:http://x',
'DNS:a b',
])
def test_generate_refuses_unsafe_san(client, db, auth_headers, cert_dir, san):
_seed_existing(cert_dir)
response = client.post(GENERATE_URL, headers=auth_headers,
json={'cn': 'ok.example.test', 'san': san,
'days': 30})
assert response.status_code == 400
assert len(list(cert_dir.iterdir())) == 2
def test_generate_installs_a_valid_pair(client, db, auth_headers, cert_dir):
from shopdb.api import AuditLog
_seed_existing(cert_dir)
response = client.post(GENERATE_URL, headers=auth_headers,
json={'cn': 'shopdb.example.test',
'san': 'DNS:shopdb.example.test,IP:10.0.0.5',
'days': 30})
assert response.status_code == 200, response.get_json()
certificate = x509.load_pem_x509_certificate(
(cert_dir / 'server.crt').read_bytes())
assert certificate.subject.get_attributes_for_oid(
NameOID.COMMON_NAME)[0].value == 'shopdb.example.test'
assert endpoint_routes._validate_certificate_pair(
(cert_dir / 'server.crt').read_bytes(),
(cert_dir / 'server.key').read_bytes()) is None
assert AuditLog.query.filter_by(
action='endpoints.server_cert.generated').count() == 1

View File

@@ -0,0 +1,162 @@
"""The two endpoints switches are declared, seeded, and have a control.
Both were readable by their enforcement code before they were declared:
plugin.endpoints.revokeddeniessharedkey agentauth.revoked_denies_shared_key
plugin.endpoints.bootstrapquerytoken bootstrap._query_token_allowed
An undeclared key has no row until somebody writes one, and until it was
declared the settings API upserted it with valuetype 'string' - so
bool(Setting.get(key, True)) read the text 'false' as True and the switch did
nothing. Declaring it fixes both halves: the row is seeded at plugin
install/enable, and a PUT lands as a real boolean.
The UI half is checked by reading the pages. A switch with no control on an
air-gapped box can only be changed with SQL, which is not a setting.
"""
from pathlib import Path
import pytest
from plugins.endpoints.api import agentauth, bootstrap
from plugins.endpoints.plugin import EndpointsPlugin
from shopdb.core.models import Setting
REPO = Path(__file__).resolve().parents[2]
VIEWS = REPO / 'plugins' / 'endpoints' / 'frontend' / 'views'
REVOKED_KEY = agentauth.REVOKED_DENIES_SHARED_KEY_SETTING
QUERY_KEY = bootstrap.QUERY_TOKEN_SETTING
def _declared():
return {entry['key']: entry for entry in
EndpointsPlugin().get_settings_defaults()}
@pytest.mark.parametrize('key,value', [(REVOKED_KEY, 'false'),
(QUERY_KEY, 'true')])
def test_the_switch_is_declared_with_todays_behaviour(key, value):
entry = _declared().get(key)
assert entry is not None, key + ' is read but never declared'
assert entry['valuetype'] == 'boolean'
assert entry['category'] == 'plugin.endpoints'
# The declared default must be what the code does with no row at all, or
# seeding a site silently changes its behaviour.
assert entry['value'] == value
@pytest.mark.parametrize('key', [REVOKED_KEY, QUERY_KEY])
def test_the_description_names_what_turning_it_on_costs(key):
"""An operator on the shop floor decides from this text alone."""
description = _declared()[key]['description']
assert len(description) > 120, description
if key == REVOKED_KEY:
# The revoke + blockreenroll rollback onto an older agent stops working.
assert 'rollback' in description
assert 'enrolls again' in description
else:
assert 're-issued' in description or 're-issue' in description
def test_the_declared_defaults_match_what_the_code_reads_with_no_row(db):
"""No Setting rows at all: the readers fall back to these same values."""
assert Setting.query.filter_by(key=REVOKED_KEY).first() is None
assert Setting.query.filter_by(key=QUERY_KEY).first() is None
assert agentauth.revoked_denies_shared_key() is False
assert bootstrap._query_token_allowed() is True
def test_the_upgrade_pass_seeds_them_on_a_site_that_installed_earlier(app, db):
"""install/enable fire only on a state transition, so a fleet already
running this plugin gets these rows from `flask plugin upgrade-all`, which
calls sync_all_plugin_settings -> _seed_plugin_settings.
This drives _seed_plugin_settings on a manager built for this test rather
than the process-wide plugin_manager singleton, and that is deliberate.
create_app() re-points that singleton, eight test modules build their own
app, and whatever ran last on this xdist worker owns it: the symptoms are
"no such table: settings" (seeding against another app's in-memory SQLite)
and "'NoneType' object has no attribute 'get_all'" (registry left None).
Both land on whichever test follows, so they move with the split - this one
passed locally and failed on CI for exactly that reason.
init_app is not called here either: it re-registers the plugin blueprints,
and twice on one app raises "The name 'backups' is already registered".
Setting _app and _db is all the seeding routine reads.
"""
from shopdb.plugins import PluginManager
from plugins.endpoints.plugin import EndpointsPlugin
assert Setting.query.filter_by(key=QUERY_KEY).first() is None
manager = PluginManager()
manager._app = app
manager._db = db
assert manager._seed_plugin_settings(EndpointsPlugin()) is True
for key, value in ((REVOKED_KEY, False), (QUERY_KEY, True)):
row = Setting.query.filter_by(key=key).one()
assert row.valuetype == 'boolean'
assert row.category == 'plugin.endpoints'
assert row.get_typed_value() is value
def test_a_put_lands_as_a_boolean_and_flips_the_query_token(client, db,
auth_headers):
"""End to end for bootstrapquerytoken through the API the page uses.
The declaration is what makes the upsert boolean; as a string row the read
below would stay True whatever was saved.
"""
saved = client.put('/api/settings/' + QUERY_KEY, json={'value': False},
headers=auth_headers)
assert saved.status_code == 200, saved.get_json()
body = saved.get_json()['data']
assert body['value'] is False
assert body['valuetype'] == 'boolean'
assert body['category'] == 'plugin.endpoints'
assert bootstrap._query_token_allowed() is False
back = client.put('/api/settings/' + QUERY_KEY, json={'value': True},
headers=auth_headers)
assert back.get_json()['data']['value'] is True
assert bootstrap._query_token_allowed() is True
def test_the_settings_api_lists_the_declared_switch(client, db, auth_headers):
"""The agent-key page reads GET /api/settings/<key> to render the box."""
client.put('/api/settings/' + QUERY_KEY, json={'value': True},
headers=auth_headers)
resp = client.get('/api/settings/' + QUERY_KEY, headers=auth_headers)
assert resp.status_code == 200, resp.get_json()
assert resp.get_json()['data']['value'] is True
def test_the_enrollment_page_carries_the_eviction_checkbox():
page = (VIEWS / 'EnrollmentSettings.vue').read_text(encoding='utf-8')
assert 'toggle-revokeddenies' in page, 'no checkbox for the eviction switch'
assert 'onRevokedDeniesToggle' in page
# It sits with the two switches it belongs beside.
assert 'toggle-sharedkey' in page and 'toggle-autoenroll' in page
# The operator has to be told what it breaks before clicking it.
assert 'rollback' in page
assert 'enrolls again' in page
def test_the_agent_key_page_carries_the_query_token_control():
page = (VIEWS / 'AgentKeySettings.vue').read_text(encoding='utf-8')
assert QUERY_KEY in page, 'the page does not touch the setting'
assert 'toggle-querytoken' in page
assert 'onQueryTokenToggle' in page
# Turning it off breaks commands already handed out, so say so.
assert 're-issue' in page

View File

@@ -1,141 +0,0 @@
"""The shipped shopfloor app monitors ride a read-only Site Policy.
Migration endpoints0007shippedpolicy offloads the eDNC/UDC/MTConnect monitors
from the agent's hardcoded appsettings.json into a product-owned policy bundle:
a shipped ConfigurationPolicy "Shopfloor App Monitors" (global scope, one
desired-state monitor action per shipped monitor) wrapped in a shipped
BusinessPolicy "Shopfloor App Monitors".
Run the endpoints chain against a fresh engine, alongside the computers policy
tables the bundle writes into, and assert the bundle exists, is source='shipped'
(read-only), and attaches every shipped monitor as a desired/running:true action.
"""
import importlib.util
import json
from pathlib import Path
from sqlalchemy import create_engine, inspect, text
from alembic.migration import MigrationContext
from alembic.operations import Operations
_ROOT = Path(__file__).resolve().parent.parent.parent
_ENDPOINTS_VERSIONS = _ROOT / 'plugins' / 'endpoints' / 'migrations' / 'versions'
_COMPUTERS_VERSIONS = _ROOT / 'plugins' / 'computers' / 'migrations' / 'versions'
CONFIG_POLICY_NAME = 'Shopfloor App Monitors'
BUSINESS_POLICY_NAME = 'Shopfloor App Monitors'
def _load(path, prefix):
spec = importlib.util.spec_from_file_location(prefix + path.stem, str(path))
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def _run_bundle_migrations(connection):
"""Build the policy tables (computers 0004), the monitor catalog (endpoints
0006) and the shipped bundle (endpoints 0007) on one engine, in order.
The core baseline creates every plugin's tables before per-plugin chains run,
so on a real deploy the computers policy tables already exist when endpoints
0007 runs. Here we stand up exactly the three migrations the bundle depends
on - the earlier endpoints revisions carry FKs to core tables that a bare
engine does not have, and are irrelevant to what 0007 seeds.
"""
operations = Operations(MigrationContext.configure(connection))
operations._install_proxy()
try:
_load(_COMPUTERS_VERSIONS / '0004_policies.py',
'computers_mig_').upgrade()
_load(_ENDPOINTS_VERSIONS / '0006_endpoint_monitors.py',
'endpoints_mig_0006_').upgrade()
_load(_ENDPOINTS_VERSIONS / '0007_shipped_monitor_policy.py',
'endpoints_mig_0007_').upgrade()
finally:
operations._remove_proxy()
connection.commit()
def test_shipped_bundle_seeds_read_only_site_policy():
engine = create_engine('sqlite://')
with engine.connect() as connection:
_run_bundle_migrations(connection)
config = connection.execute(text(
'SELECT configurationpolicyid, source, scopejson '
'FROM configurationpolicies WHERE name = :name'),
{'name': CONFIG_POLICY_NAME}).first()
assert config is not None, 'shipped Configuration Policy was not seeded'
config_id, config_source, scopejson = config
assert config_source == 'shipped'
assert json.loads(scopejson) == {'type': 'true'}
business = connection.execute(text(
'SELECT businesspolicyid, source FROM businesspolicies '
'WHERE name = :name'), {'name': BUSINESS_POLICY_NAME}).first()
assert business is not None, 'shipped Business Policy was not seeded'
business_id, business_source = business
assert business_source == 'shipped'
item = connection.execute(text(
'SELECT configurationpolicyid FROM businesspolicyitems '
'WHERE businesspolicyid = :id'), {'id': business_id}).first()
assert item is not None and item[0] == config_id
def test_shipped_bundle_attaches_every_shipped_monitor():
engine = create_engine('sqlite://')
with engine.connect() as connection:
_run_bundle_migrations(connection)
monitor_ids = {row[0] for row in connection.execute(text(
'SELECT endpointmonitorid FROM endpointmonitors '
"WHERE source = 'shipped'"))}
assert monitor_ids, 'no shipped monitors to attach'
config_id = connection.execute(text(
'SELECT configurationpolicyid FROM configurationpolicies '
'WHERE name = :name'), {'name': CONFIG_POLICY_NAME}).scalar()
actions = connection.execute(text(
'SELECT action, mode, desiredstate FROM configurationpolicyactions '
'WHERE configurationpolicyid = :id'), {'id': config_id}).all()
attached_ids = set()
for action, mode, desiredstate in actions:
assert action.startswith('monitor:')
# Gate, not desired: the shipped bundle reports state and exposes
# Start/Stop in the UI, but never auto-starts a process. A desired
# running state is a deliberate per-monitor opt-in, not shipped.
assert mode == 'gate'
assert desiredstate is None
attached_ids.add(int(action[len('monitor:'):]))
assert attached_ids == monitor_ids
def test_shipped_bundle_is_idempotent():
engine = create_engine('sqlite://')
with engine.connect() as connection:
_run_bundle_migrations(connection)
# Re-run only the shipped-bundle migration; it must not duplicate rows.
operations = Operations(MigrationContext.configure(connection))
operations._install_proxy()
try:
_load(_ENDPOINTS_VERSIONS / '0007_shipped_monitor_policy.py',
'endpoints_mig_rerun_').upgrade()
finally:
operations._remove_proxy()
connection.commit()
config_count = connection.execute(text(
'SELECT COUNT(*) FROM configurationpolicies WHERE name = :name'),
{'name': CONFIG_POLICY_NAME}).scalar()
business_count = connection.execute(text(
'SELECT COUNT(*) FROM businesspolicies WHERE name = :name'),
{'name': BUSINESS_POLICY_NAME}).scalar()
assert config_count == 1
assert business_count == 1

View File

@@ -0,0 +1,353 @@
"""Site agent package build: server pins, optional shared key, Root import gate.
The package is the only place a PC gets its first trust anchor and (optionally)
the shared fleet key, so these tests read the actual ZIP the route returns
rather than the helper in isolation.
"""
import datetime
import hashlib
import io
import json
import zipfile
import pytest
from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.x509.oid import NameOID
from plugins.endpoints.api import agentstore, siteagent
from shopdb.core.models.setting import Setting
PACKAGE_URL = '/api/endpoints/siteagent/package'
STATUS_URL = '/api/endpoints/siteagent'
SECRET = 'sharedkeyvalue-0123456789abcdef'
def _make_cert(isca):
key = ec.generate_private_key(ec.SECP256R1())
name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME,
'ca.test' if isca else 'server.test')])
now = datetime.datetime.now(datetime.timezone.utc)
builder = (x509.CertificateBuilder()
.subject_name(name).issuer_name(name)
.public_key(key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(now - datetime.timedelta(days=1))
.not_valid_after(now + datetime.timedelta(days=30)))
if isca:
builder = builder.add_extension(
x509.BasicConstraints(ca=True, path_length=None), critical=True)
certificate = builder.sign(key, hashes.SHA256())
pem = certificate.public_bytes(serialization.Encoding.PEM)
spki = certificate.public_key().public_bytes(
serialization.Encoding.DER,
serialization.PublicFormat.SubjectPublicKeyInfo)
return pem, hashlib.sha256(spki).hexdigest()
@pytest.fixture(autouse=True)
def isolate(tmp_path, monkeypatch):
"""Empty agent store and no auto-detected server certificate by default."""
store = tmp_path / 'siteagent'
store.mkdir()
monkeypatch.setattr(agentstore, '_siteagent_dir', lambda: str(store))
monkeypatch.setattr(siteagent, '_CERT_CANDIDATES', ())
return tmp_path
@pytest.fixture
def servercert(tmp_path, monkeypatch):
"""A self-signed leaf staged as this server's own certificate."""
pem, pin = _make_cert(isca=False)
path = tmp_path / 'server.crt'
path.write_bytes(pem)
monkeypatch.setattr(siteagent, '_CERT_CANDIDATES', (str(path),))
return pin
def _build(client, headers, **fields):
body = {'baseurl': 'https://shopdb.test', 'cadenceseconds': 900}
body.update(fields)
return client.post(PACKAGE_URL, json=body, headers=headers)
def _files(response):
assert response.status_code == 200, response.get_data(as_text=True)[:400]
archive = zipfile.ZipFile(io.BytesIO(response.data))
return {name: archive.read(name) for name in archive.namelist()}
def test_installer_writes_current_server_pin(client, db, auth_headers, servercert):
files = _files(_build(client, auth_headers, collectorkey=SECRET,
includesharedkey=True, bundlecert=True))
script = files['install.ps1'].decode('ascii')
assert '[string[]] $ServerPins = @("{0}")'.format(servercert) in script
assert 'Set-HklmMultiStringBothViews -SubKey "SOFTWARE\\GE\\ShopDB" -Name "ServerPins"' in script
assert '[Microsoft.Win32.RegistryValueKind]::MultiString' in script
assert '-ceq $wanted' in script
settings = json.loads(files['appsettings.json'])
assert settings['serverpins'] == [servercert]
def test_pin_comes_from_server_cert_even_when_not_bundled(
client, db, auth_headers, servercert):
files = _files(_build(client, auth_headers, collectorkey=SECRET))
assert 'server.crt' not in files
assert json.loads(files['appsettings.json'])['serverpins'] == [servercert]
def test_next_pin_is_shipped_beside_current(client, db, auth_headers, servercert):
nextpin = 'ab' * 32
files = _files(_build(client, auth_headers, collectorkey=SECRET,
nextserverpin=nextpin))
script = files['install.ps1'].decode('ascii')
assert '@("{0}", "{1}")'.format(servercert, nextpin) in script
assert json.loads(files['appsettings.json'])['serverpins'] == [servercert, nextpin]
@pytest.mark.parametrize('badpin', ['AB' * 32, 'ab' * 31, 'zz' * 32])
def test_next_pin_must_be_64_lowercase_hex(client, db, auth_headers, servercert, badpin):
response = _build(client, auth_headers, collectorkey=SECRET, nextserverpin=badpin)
assert response.status_code == 400
def test_next_pin_without_a_current_pin_is_refused(client, db, auth_headers):
response = _build(client, auth_headers, collectorkey=SECRET,
nextserverpin='ab' * 32)
assert response.status_code == 400
def test_shared_key_on_keeps_key_provisioning(client, db, auth_headers, servercert):
files = _files(_build(client, auth_headers, collectorkey=SECRET,
includesharedkey='true'))
script = files['install.ps1'].decode('ascii')
assert '[string] $CollectorKey = "{0}"'.format(SECRET) in script
assert '-Name "CollectorKey" -Value $CollectorKey' in script
assert 'Settings > Endpoints > Enrollment' not in script
def test_shared_key_off_leaves_no_key_anywhere(client, db, auth_headers, servercert):
files = _files(_build(client, auth_headers, collectorkey=SECRET,
includesharedkey=False))
for name, content in files.items():
assert b'CollectorKey' not in content, name
assert SECRET.encode('ascii') not in content, name
script = files['install.ps1'].decode('ascii')
assert 'Settings > Endpoints > Enrollment' in script
assert '$sharedKeyMode = "omitted"' in script
assert json.loads(files['appsettings.json'])['apikey'] == ''
assert b'Settings > Endpoints > Enrollment' in files['README.txt']
def test_shared_key_default_follows_site_setting(client, db, auth_headers, servercert):
# Setting on (default): key required.
assert _build(client, auth_headers).status_code == 400
Setting.set('plugin.endpoints.sharedkeyallowed', False, valuetype='boolean')
status = client.get(STATUS_URL, headers=auth_headers).get_json()['data']
assert status['sharedkeyallowed'] is False
assert status['serverpin'] == servercert
files = _files(_build(client, auth_headers, collectorkey=SECRET))
assert b'CollectorKey' not in files['install.ps1']
assert SECRET.encode('ascii') not in files['install.ps1']
def test_installer_never_removes_enrolled_credential(client, db, auth_headers, servercert):
script = _files(_build(client, auth_headers, collectorkey=SECRET))[
'install.ps1'].decode('ascii')
assert 'existing enrolled credential found; kept as is.' in script
for line in script.splitlines():
if 'Remove-Item' in line:
assert 'credential' not in line.lower(), line
def _upload(client, headers, pem, filename, **fields):
data = {'baseurl': 'https://shopdb.test', 'collectorkey': SECRET,
'cadenceseconds': '900', 'certfile': (io.BytesIO(pem), filename)}
data.update(fields)
return client.post(PACKAGE_URL, data=data,
content_type='multipart/form-data', headers=headers)
def test_ca_certificate_is_pinned_but_not_imported_into_root(
client, db, auth_headers):
capem, capin = _make_cert(isca=True)
response = _upload(client, auth_headers, capem, 'ca.crt')
files = _files(response)
script = files['install.ps1'].decode('ascii')
assert 'server.crt' in files
assert '$trustCertInRoot = $false' in script
assert 'is a CA certificate; not importing it' in script
assert 'Install-ShopDBServerCertificate.ps1' in script
assert 'older than 1.20' in script
# The SPKI pin ignores basicConstraints: the uploaded leaf is pinned.
assert '[string[]] $ServerPins = @("{0}")'.format(capin) in script
assert json.loads(files['appsettings.json'])['serverpins'] == [capin]
assert response.headers.get(siteagent.CA_WARNING_HEADER) == \
siteagent.CA_CERT_BUILD_WARNING
def test_uploaded_ca_certificate_ships_both_pins_when_server_cert_differs(
client, db, auth_headers, servercert):
capem, capin = _make_cert(isca=True)
nextpin = 'ef' * 32
response = _upload(client, auth_headers, capem, 'ca.crt',
nextserverpin=nextpin)
files = _files(response)
script = files['install.ps1'].decode('ascii')
assert '[string[]] $ServerPins = @("{0}", "{1}", "{2}")'.format(
capin, servercert, nextpin) in script
assert json.loads(files['appsettings.json'])['serverpins'] == [
capin, servercert, nextpin]
assert '$trustCertInRoot = $false' in script
warning = response.headers.get(siteagent.CA_WARNING_HEADER)
assert siteagent.CA_CERT_BUILD_WARNING in warning
assert 'both pins were included' in warning
def test_uploaded_ca_certificate_matching_server_cert_ships_one_pin(
client, db, auth_headers, tmp_path, monkeypatch):
capem, capin = _make_cert(isca=True)
path = tmp_path / 'server.crt'
path.write_bytes(capem)
monkeypatch.setattr(siteagent, '_CERT_CANDIDATES', (str(path),))
response = _upload(client, auth_headers, capem, 'server.crt')
files = _files(response)
assert json.loads(files['appsettings.json'])['serverpins'] == [capin]
assert 'both pins' not in response.headers.get(siteagent.CA_WARNING_HEADER)
@pytest.fixture
def caservercert(tmp_path, monkeypatch):
"""A CA-flagged self-signed cert staged as this server's own certificate,
as a plain openssl req -x509 produces."""
pem, pin = _make_cert(isca=True)
path = tmp_path / 'server.crt'
path.write_bytes(pem)
monkeypatch.setattr(siteagent, '_CERT_CANDIDATES', (str(path),))
return pin
def test_ca_flagged_server_cert_bundled_is_still_pinned(
client, db, auth_headers, caservercert):
nextpin = 'cd' * 32
response = _build(client, auth_headers, collectorkey=SECRET,
bundlecert=True, nextserverpin=nextpin)
files = _files(response)
script = files['install.ps1'].decode('ascii')
assert 'server.crt' in files
assert '[string[]] $ServerPins = @("{0}", "{1}")'.format(caservercert, nextpin) in script
assert json.loads(files['appsettings.json'])['serverpins'] == [caservercert, nextpin]
assert '$trustCertInRoot = $false' in script
warning = response.headers.get(siteagent.CA_WARNING_HEADER)
assert warning and 'Install-ShopDBServerCertificate.ps1' in warning
warning.encode('ascii')
def test_ca_flagged_server_cert_not_bundled_is_still_pinned(
client, db, auth_headers, caservercert):
response = _build(client, auth_headers, collectorkey=SECRET)
files = _files(response)
assert 'server.crt' not in files
assert json.loads(files['appsettings.json'])['serverpins'] == [caservercert]
assert response.headers.get(siteagent.CA_WARNING_HEADER)
status = client.get(STATUS_URL, headers=auth_headers).get_json()['data']
assert status['serverpin'] == caservercert
def test_leaf_certificate_build_has_no_warning(client, db, auth_headers, servercert):
response = _build(client, auth_headers, collectorkey=SECRET, bundlecert=True)
_files(response)
assert siteagent.CA_WARNING_HEADER not in response.headers
def test_leaf_certificate_is_imported_into_root(client, db, auth_headers):
leafpem, leafpin = _make_cert(isca=False)
response = client.post(
PACKAGE_URL,
data={'baseurl': 'https://shopdb.test', 'collectorkey': SECRET,
'cadenceseconds': '900',
'certfile': (io.BytesIO(leafpem), 'server.crt')},
content_type='multipart/form-data', headers=auth_headers)
files = _files(response)
script = files['install.ps1'].decode('ascii')
assert '$trustCertInRoot = $true' in script
assert 'Import-Certificate -FilePath $certPath -CertStoreLocation Cert:\\LocalMachine\\Root' in script
assert json.loads(files['appsettings.json'])['serverpins'] == [leafpin]
def test_installer_restricts_the_registry_key_to_system_and_admins(
client, db, auth_headers, servercert):
"""The shared key stays in HKLM until every consumer is on a per-PC
credential, so what matters meanwhile is who can read it. The key inherits a
DACL that grants BUILTIN\\Users read, which is the exposure the per-PC
migration exists to close - the installer must not leave it that way."""
files = _files(_build(client, auth_headers, collectorkey=SECRET,
includesharedkey='true'))
script = files['install.ps1'].decode('ascii')
assert 'function Protect-HklmShopdbKey' in script
assert 'Protect-HklmShopdbKey -SubKey "SOFTWARE\\GE\\ShopDB"' in script
# SYSTEM and Administrators by SID, so a localised Windows still matches.
assert '"S-1-5-18", "S-1-5-32-544"' in script
# Inheritance broken WITHOUT copying the inherited entries: copying them is
# what silently preserves the Users read.
assert 'SetAccessRuleProtection($true, $false)' in script
assert 'RemoveAccessRule($rule)' in script
def test_the_key_is_written_before_it_is_locked(client, db, auth_headers, servercert):
"""SetAccessControl replaces the DACL rather than merging, so a lock that ran
first would be undone by nothing - but a write that ran after a lock the
installer no longer holds rights for would fail. Order is the guarantee."""
script = _files(_build(client, auth_headers, collectorkey=SECRET,
includesharedkey='true'))['install.ps1'].decode('ascii')
write = script.index('-Name "CollectorKey" -Value $CollectorKey')
lock = script.index('Protect-HklmShopdbKey -SubKey')
assert write < lock
def test_the_registry_lock_failing_does_not_fail_the_install(
client, db, auth_headers, servercert):
"""A bay that cannot take the ACL still needs its agent. It must say plainly
that the key is readable there, rather than dying or staying quiet."""
script = _files(_build(client, auth_headers, collectorkey=SECRET,
includesharedkey='true'))['install.ps1'].decode('ascii')
lock = script.index('Protect-HklmShopdbKey -SubKey')
tail = script[lock:lock + 600]
assert 'catch' in tail
assert 'readable by any logged-on user' in tail
def test_the_keyless_package_still_locks_the_key(client, db, auth_headers, servercert):
"""ServerPins live in the same key and the registry is authoritative for
pinning, so a standard user must not be able to rewrite them either."""
files = _files(_build(client, auth_headers, collectorkey=SECRET,
includesharedkey=False))
script = files['install.ps1'].decode('ascii')
assert b'CollectorKey' not in files['install.ps1']
assert 'Protect-HklmShopdbKey -SubKey "SOFTWARE\\GE\\ShopDB"' in script
def test_install_script_helper_without_pins_or_key():
script = siteagent._install_script('https://shopdb.test', '', 900,
serverpins=[], includesharedkey=False)
assert '[string[]] $ServerPins = @()' in script
assert 'CollectorKey' not in script
assert 'ServerPins left as is' in script

View File

@@ -0,0 +1,224 @@
"""The shipped shopfloor monitors ride one Configuration Policy per monitor.
0007 seeded a single "Shopfloor App Monitors" bundle wrapped in a shipped Site
Policy. 0019 replaced that with one policy per monitor and removed the shipped
Site Policy, so sites compose their own.
Run the endpoints chain against a fresh engine, alongside the computers policy
tables the migrations write into, and assert the post-0019 shape: a policy per
shipped monitor, each carrying exactly one gate-mode monitor action with no PC
type attribution, and no shipped Site Policy left behind.
The idempotency test is the load-bearing one. Nothing reconciles shipped rows,
so insert-if-absent is the only thing protecting a site's edits from an upgrade
that re-runs a seeding migration - see POLICY_SOURCE_SHIPPED in
plugins/computers/models/policy.py.
"""
import importlib.util
import json
from pathlib import Path
from sqlalchemy import create_engine, text
from alembic.migration import MigrationContext
from alembic.operations import Operations
_ROOT = Path(__file__).resolve().parent.parent.parent
_ENDPOINTS_VERSIONS = _ROOT / 'plugins' / 'endpoints' / 'migrations' / 'versions'
_COMPUTERS_VERSIONS = _ROOT / 'plugins' / 'computers' / 'migrations' / 'versions'
BUNDLE_NAME = 'Shopfloor App Monitors'
SPLIT_MIGRATION = '0019_split_shipped_monitor_policies.py'
_SHIPPED_MONITORS = (
('process', 'eDNC'),
('process', 'Universal Data Collector'),
('process', 'MTConnect'),
('service', 'MTConnect'),
)
def _policy_name(kind, displayname):
return f'Monitor: {displayname} ({kind})'
def _load(path, prefix):
spec = importlib.util.spec_from_file_location(prefix + path.stem, str(path))
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def _run(connection, *loaded):
operations = Operations(MigrationContext.configure(connection))
operations._install_proxy()
try:
for module in loaded:
module.upgrade()
finally:
operations._remove_proxy()
connection.commit()
def _run_bundle_migrations(connection):
"""Build the policy tables, monitor catalog, bundle and split, in order.
The core baseline creates every plugin's tables before per-plugin chains
run, so on a real deploy the computers policy tables already exist. Here we
stand up exactly the migrations the bundle depends on - the earlier
endpoints revisions carry FKs to core tables a bare engine does not have.
"""
_run(
connection,
_load(_COMPUTERS_VERSIONS / '0004_policies.py', 'computers_mig_'),
_load(_ENDPOINTS_VERSIONS / '0006_endpoint_monitors.py',
'endpoints_mig_0006_'),
_load(_ENDPOINTS_VERSIONS / '0007_shipped_monitor_policy.py',
'endpoints_mig_0007_'),
_load(_ENDPOINTS_VERSIONS / SPLIT_MIGRATION, 'endpoints_mig_0019_'),
)
def _monitor_id(connection, kind, displayname):
return connection.execute(text(
'SELECT endpointmonitorid FROM endpointmonitors '
'WHERE kind = :kind AND displayname = :displayname'),
{'kind': kind, 'displayname': displayname}).scalar()
def test_split_seeds_one_policy_per_shipped_monitor():
engine = create_engine('sqlite://')
with engine.connect() as connection:
_run_bundle_migrations(connection)
for kind, displayname in _SHIPPED_MONITORS:
name = _policy_name(kind, displayname)
row = connection.execute(text(
'SELECT configurationpolicyid, source, scopejson '
'FROM configurationpolicies WHERE name = :name'),
{'name': name}).first()
assert row is not None, f'{name} was not seeded'
policyid, source, scopejson = row
assert source == 'shipped'
# No PC type attribution: the policy applies wherever a site
# composes it, and carries no opinion about which types match.
assert json.loads(scopejson) == {'type': 'true'}
actions = connection.execute(text(
'SELECT action, mode, desiredstate '
'FROM configurationpolicyactions '
'WHERE configurationpolicyid = :id'), {'id': policyid}).all()
assert len(actions) == 1, f'{name} should carry exactly one action'
action, mode, desiredstate = actions[0]
# Gate, not desired: a monitor reports state and exposes Start/Stop
# but never auto-starts a process. A desired running state is a
# deliberate per-monitor opt-in, not something the product ships on.
assert mode == 'gate'
assert desiredstate is None
expected = _monitor_id(connection, kind, displayname)
assert action == f'monitor:{expected}'
def test_split_removes_the_shipped_site_policy():
engine = create_engine('sqlite://')
with engine.connect() as connection:
_run_bundle_migrations(connection)
business = connection.execute(text(
'SELECT COUNT(*) FROM businesspolicies WHERE name = :name'),
{'name': BUNDLE_NAME}).scalar()
assert business == 0, 'the shipped Site Policy must not survive 0019'
config = connection.execute(text(
'SELECT COUNT(*) FROM configurationpolicies WHERE name = :name'),
{'name': BUNDLE_NAME}).scalar()
assert config == 0, 'the bundled Configuration Policy must be retired'
items = connection.execute(text(
'SELECT COUNT(*) FROM businesspolicyitems')).scalar()
assert items == 0
def test_no_shipped_business_policy_remains():
"""Sites build their own composition; the product ships none."""
engine = create_engine('sqlite://')
with engine.connect() as connection:
_run_bundle_migrations(connection)
shipped = connection.execute(text(
"SELECT COUNT(*) FROM businesspolicies WHERE source = 'shipped'"
)).scalar()
assert shipped == 0
def test_split_is_idempotent_and_preserves_site_edits():
"""A re-run must not duplicate rows nor stomp an edit.
Nothing reconciles shipped rows, so this is the whole protection: the
migration returns early when the policy name is already present. If that
ever becomes a reconciling seeder, this test is what fails.
"""
engine = create_engine('sqlite://')
with engine.connect() as connection:
_run_bundle_migrations(connection)
edited = _policy_name('process', 'eDNC')
connection.execute(text(
'UPDATE configurationpolicies SET description = :description '
'WHERE name = :name'),
{'description': 'site edited', 'name': edited})
connection.commit()
_run(connection, _load(_ENDPOINTS_VERSIONS / SPLIT_MIGRATION,
'endpoints_mig_rerun_'))
for kind, displayname in _SHIPPED_MONITORS:
count = connection.execute(text(
'SELECT COUNT(*) FROM configurationpolicies '
'WHERE name = :name'),
{'name': _policy_name(kind, displayname)}).scalar()
assert count == 1
description = connection.execute(text(
'SELECT description FROM configurationpolicies WHERE name = :name'),
{'name': edited}).scalar()
assert description == 'site edited', 'a re-run stomped a site edit'
def test_downgrade_restores_the_bundle():
engine = create_engine('sqlite://')
with engine.connect() as connection:
_run_bundle_migrations(connection)
split = _load(_ENDPOINTS_VERSIONS / SPLIT_MIGRATION,
'endpoints_mig_down_')
operations = Operations(MigrationContext.configure(connection))
operations._install_proxy()
try:
split.downgrade()
finally:
operations._remove_proxy()
connection.commit()
config_id = connection.execute(text(
'SELECT configurationpolicyid FROM configurationpolicies '
'WHERE name = :name'), {'name': BUNDLE_NAME}).scalar()
assert config_id is not None, 'downgrade did not restore the bundle'
business_id = connection.execute(text(
'SELECT businesspolicyid FROM businesspolicies WHERE name = :name'),
{'name': BUNDLE_NAME}).scalar()
assert business_id is not None
item = connection.execute(text(
'SELECT configurationpolicyid FROM businesspolicyitems '
'WHERE businesspolicyid = :id'), {'id': business_id}).first()
assert item is not None and item[0] == config_id
for kind, displayname in _SHIPPED_MONITORS:
count = connection.execute(text(
'SELECT COUNT(*) FROM configurationpolicies '
'WHERE name = :name'),
{'name': _policy_name(kind, displayname)}).scalar()
assert count == 0, 'downgrade left the split policies behind'

View File

@@ -0,0 +1,51 @@
"""The Storage panel serves volumes and disk health as two sections.
Visual grouping only. The two tables share no key - storage is per volume and
health is per physical device - so nothing is reconciled across them. These
tests pin that the sections stay independent and that neither disappears when
empty, because a dropped section takes its tab with it and reads as "no disks"
rather than "nothing was reported".
"""
import pytest
from plugins.endpoints.api.routes import _storage_rows, _STORAGE_COLUMNS, _DISKHEALTH_COLUMNS
def test_freepercent_is_derived_when_an_older_agent_omits_it():
rows = _storage_rows([{'drive': 'C:', 'sizebytes': 1000, 'freebytes': 250}])
assert rows[0]['freepercent'] == '25.0%'
def test_an_agent_supplied_freepercent_is_preferred_over_recomputing():
rows = _storage_rows([
{'drive': 'C:', 'sizebytes': 1000, 'freebytes': 250, 'freepercent': 24.9},
])
assert rows[0]['freepercent'] == '24.9%'
def test_a_zero_size_volume_does_not_divide_by_zero():
rows = _storage_rows([{'drive': 'C:', 'sizebytes': 0, 'freebytes': 0}])
assert rows[0]['freepercent'] is None
def test_free_percent_is_none_rather_than_zero_when_free_is_unknown():
"""Zero free space and unknown free space must not look identical."""
rows = _storage_rows([{'drive': 'C:', 'sizebytes': 1000}])
assert rows[0]['freepercent'] is None
def test_non_dict_entries_are_skipped():
rows = _storage_rows([{'drive': 'C:', 'sizebytes': 1000, 'freebytes': 500}, 'junk', None])
assert len(rows) == 1
@pytest.mark.parametrize('value', [None, {}, 'x'])
def test_a_missing_snapshot_yields_no_rows(value):
assert _storage_rows(value) == []
def test_the_two_section_column_sets_share_no_key():
"""The point of two sections rather than one merged table."""
storage = {column['key'] for column in _STORAGE_COLUMNS}
health = {column['key'] for column in _DISKHEALTH_COLUMNS}
assert not storage & health

View File

@@ -1,108 +0,0 @@
"""GE-Enforce manifestblobs model-vs-migration parity gate.
manifestblobs is created by the SECOND geenforce migration
(0002_manifest_blobs.py), and the existing DDL-parity test only diffs the
baseline (0001) tables - it does not even list ManifestBlob. So the http-blob
store table has had NO parity coverage: a column added to the ManifestBlob model
but not the migration (or vice versa) would ship a broken schema silently.
This gate builds manifestblobs two ways and diffs it column-by-column:
1. the ManifestBlob model via metadata.create_all (desired shape), and
2. the 0002 migration's upgrade() run against a fresh engine (shipped shape).
Both go through the same SQLite dialect so reflected types render identically.
"""
import importlib.util
from pathlib import Path
from sqlalchemy import create_engine, inspect
from alembic.migration import MigrationContext
from alembic.operations import Operations
_BLOBS_MIGRATION = (Path(__file__).resolve().parent.parent.parent / 'plugins' /
'geenforce' / 'migrations' / 'versions' /
'0002_manifest_blobs.py')
_EXPECTED_COLUMNS = ('sha256', 'filename', 'contenttype', 'sizebytes',
'createdat')
def _migration_columns():
"""Run the 0002 migration upgrade() on a fresh engine; return the reflected
manifestblobs columns as {name: column-dict}."""
spec = importlib.util.spec_from_file_location('geenforce_blobs',
str(_BLOBS_MIGRATION))
migration = importlib.util.module_from_spec(spec)
spec.loader.exec_module(migration)
engine = create_engine('sqlite://')
with engine.connect() as connection:
operations = Operations(MigrationContext.configure(connection))
operations._install_proxy()
try:
migration.upgrade()
finally:
operations._remove_proxy()
connection.commit()
inspector = inspect(connection)
assert 'manifestblobs' in inspector.get_table_names(), \
'migration did not create manifestblobs'
return {column['name']: column
for column in inspector.get_columns('manifestblobs')}
def _model_columns(db):
"""Build manifestblobs from the ManifestBlob model; return {name: column}."""
from plugins.geenforce.models.manifest import ManifestBlob
engine = create_engine('sqlite://')
db.metadata.create_all(engine, tables=[ManifestBlob.__table__])
return {column['name']: column
for column in inspect(engine).get_columns('manifestblobs')}
def _colkey(column):
"""Normalize a reflected column to the facets we gate on (type, nullable)."""
return (str(column['type']), bool(column['nullable']))
def test_manifestblob_model_matches_migration(app, db):
"""Every ManifestBlob column matches what the 0002 migration builds."""
with app.app_context():
modelcols = _model_columns(db)
migrationcols = _migration_columns()
drift = []
for name in sorted(set(modelcols) - set(migrationcols)):
drift.append(f'{name}: in model, not in migration')
for name in sorted(set(migrationcols) - set(modelcols)):
drift.append(f'{name}: in migration, not in model')
for name in sorted(set(modelcols) & set(migrationcols)):
if _colkey(modelcols[name]) != _colkey(migrationcols[name]):
drift.append(
f'{name}: model {_colkey(modelcols[name])} != '
f'migration {_colkey(migrationcols[name])}')
assert not drift, ('manifestblobs model/migration DDL drift:\n'
+ '\n'.join(drift))
def test_manifestblob_expected_columns_present_both_sides(app, db):
"""Lock the load-bearing columns (sha256 primary key + registry fields)
exist in BOTH the model and the migration."""
with app.app_context():
modelcols = _model_columns(db)
migrationcols = _migration_columns()
for name in _EXPECTED_COLUMNS:
assert name in modelcols, f'{name} missing from ManifestBlob model'
assert name in migrationcols, f'{name} missing from migration'
def test_manifestblob_sha256_is_not_nullable(app, db):
"""The content hash is the primary key: it must be NOT NULL on both sides."""
with app.app_context():
modelcols = _model_columns(db)
migrationcols = _migration_columns()
assert modelcols['sha256']['nullable'] is False
assert migrationcols['sha256']['nullable'] is False

View File

@@ -1,166 +0,0 @@
"""GE-Enforce compliance: fleet-install coverage per app-linked entry.
Compliance pairs each app-linked manifest entry with collected PC data
(ComputerInstalledApp) to answer 'how many PCs have this app, and how many on
the expected version'. Only entries carrying a curated appid appear; unlinked
entries are skipped. FileVersion detection supplies the expected-version target;
every other detection method yields a null target (no meaningful match). When
the optional computers plugin is absent, counts degrade to null gracefully.
"""
from shopdb.core.models import Asset, AssetType, Application
from plugins.geenforce import service
def _seed_app(db, appname):
app_row = Application(appname=appname)
db.session.add(app_row)
db.session.commit()
return app_row.appid
def _seed_pc_with_app(db, hostname, appid, installedversion, isactive=True):
"""Create a Computer + one ComputerInstalledApp row for it."""
from plugins.computers.models import Computer, ComputerInstalledApp
atype = AssetType.query.filter_by(assettype='computer').first()
if not atype:
atype = AssetType(assettype='computer')
db.session.add(atype)
db.session.flush()
asset = Asset(assetnumber=f'PC-{hostname}', assettypeid=atype.assettypeid)
db.session.add(asset)
db.session.flush()
comp = Computer(assetid=asset.assetid, hostname=hostname)
db.session.add(comp)
db.session.flush()
link = ComputerInstalledApp(computerid=comp.computerid, appid=appid,
installedversion=installedversion,
isactive=isactive)
db.session.add(link)
db.session.commit()
return comp
def _create_scope(client, auth_headers, name='gea-shopfloor-cmm'):
resp = client.post('/api/geenforce/scopes',
json={'scopename': name, 'phase': 'runtime'},
headers=auth_headers)
assert resp.status_code == 201, resp.get_json()
return resp.get_json()['data']['scopeid']
def _add_entry(client, auth_headers, scopeid, entry):
resp = client.post(f'/api/geenforce/scopes/{scopeid}/entries',
json=entry, headers=auth_headers)
assert resp.status_code == 201, resp.get_json()
return resp.get_json()['data']['entryid']
def test_compliance_only_lists_app_linked_entries(client, db, auth_headers):
appid = _seed_app(db, 'PC-DMIS')
scopeid = _create_scope(client, auth_headers)
_add_entry(client, auth_headers, scopeid,
{'Name': 'PC-DMIS 2019', 'Type': 'MSI', 'appid': appid,
'DetectionMethod': 'FileVersion', 'DetectionPath': 'C:\\p.exe',
'DetectionValue': '2019 R1'})
# An unlinked entry (no appid) must NOT appear.
_add_entry(client, auth_headers, scopeid,
{'Name': 'Unlinked', 'Type': 'MSI', 'Installer': 'apps/x.msi'})
data = client.get(f'/api/geenforce/scopes/{scopeid}/compliance',
headers=auth_headers).get_json()['data']
assert data['computersplugin'] is True
assert [r['entryname'] for r in data['rows']] == ['PC-DMIS 2019']
row = data['rows'][0]
assert row['appid'] == appid
assert row['appname'] == 'PC-DMIS'
assert row['expectedversion'] == '2019 R1'
def test_compliance_counts_active_installs_and_version_match(client, db,
auth_headers):
appid = _seed_app(db, 'PC-DMIS')
# Two PCs on the expected version, one on a different version, one inactive.
_seed_pc_with_app(db, 'CMM01', appid, '2019 R1')
_seed_pc_with_app(db, 'CMM02', appid, '2019 R1')
_seed_pc_with_app(db, 'CMM03', appid, '2016 R2')
_seed_pc_with_app(db, 'CMM04', appid, '2019 R1', isactive=False)
scopeid = _create_scope(client, auth_headers)
_add_entry(client, auth_headers, scopeid,
{'Name': 'PC-DMIS 2019', 'Type': 'MSI', 'appid': appid,
'DetectionMethod': 'FileVersion', 'DetectionPath': 'C:\\p.exe',
'DetectionValue': '2019 R1'})
row = client.get(f'/api/geenforce/scopes/{scopeid}/compliance',
headers=auth_headers).get_json()['data']['rows'][0]
# Inactive row excluded: 3 active installs, 2 on the expected version.
assert row['installedcount'] == 3
assert row['versionmatchcount'] == 2
assert '2 of 3' in row['coveragenote']
def test_compliance_non_fileversion_has_null_version_target(client, db,
auth_headers):
appid = _seed_app(db, 'eDNC')
_seed_pc_with_app(db, 'DNC01', appid, '5.0')
scopeid = _create_scope(client, auth_headers)
# Registry detection is not a version string -> no expected-version target.
_add_entry(client, auth_headers, scopeid,
{'Name': 'eDNC', 'Type': 'MSI', 'appid': appid,
'DetectionMethod': 'Registry',
'DetectionPath': 'HKLM\\Software\\eDNC'})
row = client.get(f'/api/geenforce/scopes/{scopeid}/compliance',
headers=auth_headers).get_json()['data']['rows'][0]
assert row['expectedversion'] is None
assert row['installedcount'] == 1
assert row['versionmatchcount'] is None
assert 'no version target' in row['coveragenote']
def test_compliance_not_installed_anywhere(client, db, auth_headers):
appid = _seed_app(db, 'Orphan')
scopeid = _create_scope(client, auth_headers)
_add_entry(client, auth_headers, scopeid,
{'Name': 'Orphan', 'Type': 'MSI', 'appid': appid,
'DetectionMethod': 'FileVersion', 'DetectionPath': 'C:\\o.exe',
'DetectionValue': '1.0'})
row = client.get(f'/api/geenforce/scopes/{scopeid}/compliance',
headers=auth_headers).get_json()['data']['rows'][0]
assert row['installedcount'] == 0
assert row['versionmatchcount'] == 0
assert row['coveragenote'] == 'not installed on any collected PC'
def test_compliance_scope_not_found(client, db, auth_headers):
resp = client.get('/api/geenforce/scopes/999999/compliance',
headers=auth_headers)
assert resp.status_code == 404
def test_compliance_requires_manage_permission(client, db, member_headers):
resp = client.get('/api/geenforce/scopes/1/compliance',
headers=member_headers)
assert resp.status_code == 403
def test_compliance_graceful_without_computers_plugin(client, db, auth_headers,
monkeypatch):
appid = _seed_app(db, 'PC-DMIS')
scopeid = _create_scope(client, auth_headers)
_add_entry(client, auth_headers, scopeid,
{'Name': 'PC-DMIS 2019', 'Type': 'MSI', 'appid': appid,
'DetectionMethod': 'FileVersion', 'DetectionPath': 'C:\\p.exe',
'DetectionValue': '2019 R1'})
monkeypatch.setattr(service, '_installed_app_model', lambda: None)
data = client.get(f'/api/geenforce/scopes/{scopeid}/compliance',
headers=auth_headers).get_json()['data']
assert data['computersplugin'] is False
row = data['rows'][0]
assert row['installedcount'] is None
assert row['versionmatchcount'] is None
assert 'computers plugin not installed' in row['coveragenote']

View File

@@ -1,278 +0,0 @@
"""GE-Enforce P2 admin CRUD: scopes, entries, reorder, simulate, publish cycle.
All admin endpoints are JWT + geenforce.manage/publish gated (the admin user
holds both). Entry payloads use the manifest Applications[] shape.
"""
def _create_scope(client, auth_headers, name='gea-shopfloor-test'):
resp = client.post('/api/geenforce/scopes',
json={'scopename': name, 'phase': 'runtime',
'description': 'test scope'},
headers=auth_headers)
assert resp.status_code == 201, resp.get_json()
return resp.get_json()['data']['scopeid']
def _add_entry(client, auth_headers, scopeid, entry):
resp = client.post(f'/api/geenforce/scopes/{scopeid}/entries',
json=entry, headers=auth_headers)
assert resp.status_code == 201, resp.get_json()
return resp.get_json()['data']['entryid']
def test_scope_crud(client, db, auth_headers):
scopeid = _create_scope(client, auth_headers)
got = client.get(f'/api/geenforce/scopes/{scopeid}', headers=auth_headers)
assert got.status_code == 200
assert got.get_json()['data']['scopename'] == 'gea-shopfloor-test'
upd = client.put(f'/api/geenforce/scopes/{scopeid}',
json={'description': 'updated', 'computertypeid': 3},
headers=auth_headers)
assert upd.get_json()['data']['computertypeid'] == 3
dele = client.delete(f'/api/geenforce/scopes/{scopeid}', headers=auth_headers)
assert dele.status_code == 200
assert client.get(f'/api/geenforce/scopes/{scopeid}',
headers=auth_headers).status_code == 404
def test_duplicate_scope_rejected(client, db, auth_headers):
_create_scope(client, auth_headers, 'gea-shopfloor-dup')
resp = client.post('/api/geenforce/scopes',
json={'scopename': 'gea-shopfloor-dup', 'phase': 'runtime'},
headers=auth_headers)
assert resp.status_code == 400
def test_entry_crud_and_reorder(client, db, auth_headers):
scopeid = _create_scope(client, auth_headers)
a = _add_entry(client, auth_headers, scopeid,
{'Name': 'Alpha', 'Type': 'MSI', 'Installer': 'apps/a.msi'})
b = _add_entry(client, auth_headers, scopeid,
{'Name': 'Beta', 'Type': 'PS1', 'Script': 'scripts/b.ps1'})
# Update Alpha: change type + add detection.
upd = client.put(f'/api/geenforce/entries/{a}',
json={'Name': 'Alpha', 'Type': 'EXE', 'Installer': 'apps/a.exe',
'DetectionMethod': 'FileVersion',
'DetectionPath': 'C:\\a.exe', 'DetectionValue': '1.0'},
headers=auth_headers)
assert upd.status_code == 200
assert upd.get_json()['data']['Type'] == 'EXE'
assert upd.get_json()['data']['DetectionMethod'] == 'FileVersion'
# Reorder: Beta first.
reo = client.put(f'/api/geenforce/scopes/{scopeid}/entries/reorder',
json={'order': [b, a]}, headers=auth_headers)
assert reo.status_code == 200
entries = client.get(f'/api/geenforce/scopes/{scopeid}',
headers=auth_headers).get_json()['data']['entries']
assert [e['Name'] for e in entries] == ['Beta', 'Alpha']
# Delete Beta.
assert client.delete(f'/api/geenforce/entries/{b}',
headers=auth_headers).status_code == 200
entries = client.get(f'/api/geenforce/scopes/{scopeid}',
headers=auth_headers).get_json()['data']['entries']
assert [e['Name'] for e in entries] == ['Alpha']
def test_entry_edit_preserves_full_fidelity(client, db, auth_headers):
"""Editing an entry with the complete payload keeps InUseCheck ExePath +
timeout, LogFile, and preinstall flags (the UI now sends all of them)."""
scopeid = _create_scope(client, auth_headers)
rich = {
'Name': 'PC-DMIS', 'Type': 'MSI', 'Installer': 'apps/pcdmis.msi',
'LogFile': 'C:\\Logs\\pcdmis.log',
'PreEnrollment': True, 'PCTypesStrict': True,
'InUseCheck': {'Behavior': 'CloseAndReopen', 'Processes': [
{'Name': 'PCDLRN', 'ExePath': 'C:\\PCDLRN.exe',
'GracefulCloseTimeoutSec': 15}]},
}
entryid = _add_entry(client, auth_headers, scopeid, rich)
# Re-send the same full payload (what the editor now does) and confirm
# nothing is stripped.
upd = client.put(f'/api/geenforce/entries/{entryid}', json=rich,
headers=auth_headers)
assert upd.status_code == 200
entry = upd.get_json()['data']
assert entry['LogFile'] == 'C:\\Logs\\pcdmis.log'
assert entry['PreEnrollment'] is True
assert entry['PCTypesStrict'] is True
proc = entry['InUseCheck']['Processes'][0]
assert proc['ExePath'] == 'C:\\PCDLRN.exe'
assert proc['GracefulCloseTimeoutSec'] == 15
def test_entry_curated_app_link(client, db, auth_headers):
"""An entry can be linked to an existing Application; the link is shopdb
metadata and must NOT leak into the manifest JSON (parity/enforcement)."""
from shopdb.core.models import Application
app_row = Application(appname='eDNC (tracked)')
db.session.add(app_row)
db.session.commit()
appid = app_row.appid
scopeid = _create_scope(client, auth_headers)
created = client.post(f'/api/geenforce/scopes/{scopeid}/entries',
json={'Name': 'eDNC install', 'Type': 'MSI',
'appid': appid}, headers=auth_headers)
assert created.status_code == 201, created.get_json()
data = created.get_json()['data']
assert data['appid'] == appid
assert data['appname'] == 'eDNC (tracked)'
# the link is NOT in the rendered manifest (would break the engine contract)
manifest = client.get(f'/api/geenforce/scopes/{scopeid}/preview',
headers=auth_headers).get_json()['data']['manifest']
entry_json = manifest['Applications'][0]
assert 'appid' not in entry_json and 'appname' not in entry_json
# unlink
updated = client.put(f"/api/geenforce/entries/{data['entryid']}",
json={'Name': 'eDNC install', 'Type': 'MSI', 'appid': None},
headers=auth_headers)
assert updated.get_json()['data']['appid'] is None
# picker lists the app
picker = client.get('/api/geenforce/applications',
headers=auth_headers).get_json()['data']
assert any(a['appname'] == 'eDNC (tracked)' for a in picker)
def test_entry_nonnumeric_appid_ignored(client, db, auth_headers):
"""A non-numeric appid must be ignored (link stays null), not 500. Matches
the _apply_app_link docstring contract."""
scopeid = _create_scope(client, auth_headers)
created = client.post(f'/api/geenforce/scopes/{scopeid}/entries',
json={'Name': 'x', 'Type': 'MSI', 'appid': 'notanumber'},
headers=auth_headers)
assert created.status_code == 201, created.get_json()
assert created.get_json()['data']['appid'] is None
def test_invalid_entry_type_rejected(client, db, auth_headers):
scopeid = _create_scope(client, auth_headers)
resp = client.post(f'/api/geenforce/scopes/{scopeid}/entries',
json={'Name': 'Bad', 'Type': 'NOTATYPE'},
headers=auth_headers)
assert resp.status_code == 400
def test_reorder_rejects_foreign_ids(client, db, auth_headers):
scopeid = _create_scope(client, auth_headers)
a = _add_entry(client, auth_headers, scopeid,
{'Name': 'Alpha', 'Type': 'MSI'})
resp = client.put(f'/api/geenforce/scopes/{scopeid}/entries/reorder',
json={'order': [a, 9999]}, headers=auth_headers)
assert resp.status_code == 400
def test_simulate_cmm_version_gate(client, db, auth_headers):
scopeid = _create_scope(client, auth_headers, 'gea-shopfloor-cmm')
_add_entry(client, auth_headers, scopeid,
{'Name': 'CLM (untagged)', 'Type': 'MSI', 'Installer': 'apps/clm.msi'})
_add_entry(client, auth_headers, scopeid,
{'Name': 'PC-DMIS 2016', 'Type': 'MSI', 'Installer': 'apps/16.msi',
'_CmmVersion': '2016'})
_add_entry(client, auth_headers, scopeid,
{'Name': 'PC-DMIS 2019', 'Type': 'MSI', 'Installer': 'apps/19.msi',
'_CmmVersion': '2019'})
sim = client.get(f'/api/geenforce/scopes/{scopeid}/simulate?cmmversion=2019',
headers=auth_headers)
assert sim.status_code == 200
data = sim.get_json()['data']
assert data['applied'] == ['CLM (untagged)', 'PC-DMIS 2019']
filtered = {f['name']: f['filteredby'] for f in data['filtered']}
assert filtered['PC-DMIS 2016'] == ['_CmmVersion']
def test_duplicate_entry_name_is_400_not_500(client, db, auth_headers):
scopeid = _create_scope(client, auth_headers)
_add_entry(client, auth_headers, scopeid, {'Name': 'Alpha', 'Type': 'MSI'})
dup = client.post(f'/api/geenforce/scopes/{scopeid}/entries',
json={'Name': 'Alpha', 'Type': 'PS1'}, headers=auth_headers)
assert dup.status_code == 400
def test_simulate_pctypesstrict_disables_alias(client, db, auth_headers):
"""A collections-only strict entry must NOT match a nocollections PC via the
shared Standard alias group. PCTypesStrict is preinstall-only, so the scope
must be the preinstall phase (the runtime engine ignores strict)."""
resp = client.post('/api/geenforce/scopes',
json={'scopename': 'preinstall', 'phase': 'preinstall'},
headers=auth_headers)
assert resp.status_code == 201, resp.get_json()
scopeid = resp.get_json()['data']['scopeid']
_add_entry(client, auth_headers, scopeid,
{'Name': 'UDC (strict)', 'Type': 'EXE', 'Installer': 'apps/udc.exe',
'PCTypes': ['gea-shopfloor-collections'], 'PCTypesStrict': True})
_add_entry(client, auth_headers, scopeid,
{'Name': 'UDC (loose)', 'Type': 'EXE', 'Installer': 'apps/udc.exe',
'PCTypes': ['gea-shopfloor-collections']})
sim = client.get(
f'/api/geenforce/scopes/{scopeid}/simulate?pctype=gea-shopfloor-nocollections',
headers=auth_headers).get_json()['data']
# loose entry matches via the Standard alias group; strict entry does not.
assert sim['applied'] == ['UDC (loose)']
filtered = {f['name']: f['filteredby'] for f in sim['filtered']}
assert filtered['UDC (strict)'] == ['PCTypes']
def test_simulate_machine_number_gate(client, db, auth_headers):
scopeid = _create_scope(client, auth_headers, 'gea-shopfloor-collections')
_add_entry(client, auth_headers, scopeid,
{'Name': 'Okuma cred', 'Type': 'PS1', 'Script': 'scripts/o.ps1',
'TargetMachineNumbers': ['3201', '3202']})
_add_entry(client, auth_headers, scopeid,
{'Name': 'Everyone', 'Type': 'PS1', 'Script': 'scripts/e.ps1'})
match = client.get(
f'/api/geenforce/scopes/{scopeid}/simulate?machinenumber=3201',
headers=auth_headers).get_json()['data']
assert set(match['applied']) == {'Okuma cred', 'Everyone'}
nomatch = client.get(
f'/api/geenforce/scopes/{scopeid}/simulate?machinenumber=9999',
headers=auth_headers).get_json()['data']
assert nomatch['applied'] == ['Everyone']
def test_publish_versions_rollback(client, db, auth_headers):
scopeid = _create_scope(client, auth_headers, 'gea-shopfloor-cmm')
_add_entry(client, auth_headers, scopeid, {'Name': 'Alpha', 'Type': 'MSI'})
pub1 = client.post(f'/api/geenforce/scopes/{scopeid}/publish',
json={'notes': 'v1'}, headers=auth_headers)
assert pub1.status_code == 201
assert pub1.get_json()['data']['versionnumber'] == 1
_add_entry(client, auth_headers, scopeid, {'Name': 'Beta', 'Type': 'PS1'})
pub2 = client.post(f'/api/geenforce/scopes/{scopeid}/publish',
json={'notes': 'v2'}, headers=auth_headers)
assert pub2.get_json()['data']['versionnumber'] == 2
versions = client.get(f'/api/geenforce/scopes/{scopeid}/versions',
headers=auth_headers).get_json()['data']
assert [v['versionnumber'] for v in versions] == [2, 1]
assert versions[0]['iscurrent'] is True
# v2 manifest has both entries.
v2doc = client.get(f'/api/geenforce/scopes/{scopeid}/versions/2',
headers=auth_headers).get_json()['data']['manifest']
assert [e['Name'] for e in v2doc['Applications']] == ['Alpha', 'Beta']
# Rollback to v1.
rb = client.post(f'/api/geenforce/scopes/{scopeid}/rollback',
json={'versionnumber': 1}, headers=auth_headers)
assert rb.status_code == 200
versions = client.get(f'/api/geenforce/scopes/{scopeid}/versions',
headers=auth_headers).get_json()['data']
current = next(v for v in versions if v['iscurrent'])
assert current['versionnumber'] == 1

View File

@@ -1,123 +0,0 @@
"""GE-Enforce dashboard card: what is broken on the floor right now.
The reports table has always held enforcement failures - the failing entry, its
exit code, the engine's message - and the only way to see any of it was to open
one PC's report modal, one PC at a time. A bay returned 500 to every collector
report for a day and a half before anyone looked. This card is the fix, so
these tests pin the behaviour that makes it useful rather than decorative.
"""
from datetime import datetime
from plugins.geenforce.models import (ManifestEnforcementReport,
ManifestEnforcementResult)
URL = '/api/geenforce/dashboard/failures'
def _report(db, hostname, iscurrent=True, results=(), scopename='gea-shopfloor-cmm',
receivedat=None):
report = ManifestEnforcementReport(
hostname=hostname, scopename=scopename, phase='runtime',
status='failed', iscurrent=iscurrent,
receivedat=receivedat or datetime(2026, 8, 11, 12, 0, 0))
db.session.add(report)
db.session.flush()
for entryname, action, exitcode, message in results:
db.session.add(ManifestEnforcementResult(
reportid=report.reportid, entryname=entryname, action=action,
exitcode=exitcode, message=message))
db.session.commit()
return report
def test_a_failed_entry_is_listed_with_what_a_person_needs(client, db, auth_headers):
"""Per ENTRY, not per report. "Three PCs failed" is a number; "Install
OpenText failed with exit 1603 on WJSF1234" is something to act on."""
_report(db, 'WJSF1234', results=[
('Install OpenText', 'failed', 1603, 'Fatal error during installation')])
rows = client.get(URL, headers=auth_headers).get_json()['data']
assert len(rows) == 1
assert rows[0]['hostname'] == 'WJSF1234'
assert rows[0]['entryname'] == 'Install OpenText'
assert rows[0]['exitcode'] == 1603
assert 'Fatal error' in rows[0]['message']
def test_only_failures_appear(client, db, auth_headers):
"""A cycle installs, skips and filters far more than it fails. Listing
anything but failures is how a card becomes wallpaper."""
_report(db, 'WJSF1234', results=[
('Install OpenText', 'failed', 1603, 'boom'),
('Install Acrobat', 'installed', 0, ''),
('Set FMS host', 'skipped', 0, ''),
('CMM settings', 'filtered', 0, 'PCTypes filter')])
rows = client.get(URL, headers=auth_headers).get_json()['data']
assert [r['entryname'] for r in rows] == ['Install OpenText']
def test_a_fixed_failure_disappears_on_its_own(client, db, auth_headers):
"""Superseded reports are not current. A failure that has since been fixed
must clear itself rather than needing someone to dismiss it - otherwise the
card accumulates history and stops meaning "right now"."""
_report(db, 'WJSF1234', iscurrent=False, results=[
('Install OpenText', 'failed', 1603, 'boom')])
_report(db, 'WJSF1234', iscurrent=True, results=[
('Install OpenText', 'installed', 0, '')])
assert client.get(URL, headers=auth_headers).get_json()['data'] == []
def test_the_row_links_to_the_pc_when_shopdb_knows_it(client, db, auth_headers):
"""A card that names a PC without linking to it is a worse report."""
from plugins.computers.models import Computer
from shopdb.core.models import Asset, AssetType
atype = AssetType.query.filter_by(assettype='computer').first()
if not atype:
atype = AssetType(assettype='computer')
db.session.add(atype)
db.session.flush()
asset = Asset(assetnumber='WJSF1234',
assettypeid=atype.assettypeid, statusid=1)
db.session.add(asset)
db.session.flush()
comp = Computer(assetid=asset.assetid, hostname='WJSF1234')
db.session.add(comp)
db.session.commit()
_report(db, 'WJSF1234', results=[('Install OpenText', 'failed', 1603, 'x')])
rows = client.get(URL, headers=auth_headers).get_json()['data']
assert rows[0]['computerid'] == comp.computerid
def test_a_pc_shopdb_does_not_know_still_reports_its_failure(client, db,
auth_headers):
"""The failure is real even when the inventory is behind. Dropping the row
would hide exactly the bay most likely to be misconfigured."""
_report(db, 'GHOSTPC', results=[('Install OpenText', 'failed', 1603, 'x')])
rows = client.get(URL, headers=auth_headers).get_json()['data']
assert len(rows) == 1
assert rows[0]['computerid'] is None
def test_the_card_is_permission_gated(client, db, member_headers):
"""A dashboard card must not become a way around RBAC."""
assert client.get(URL, headers=member_headers).status_code == 403
def test_the_widget_declares_data_and_a_renderer_not_a_component(app):
"""The old contract named a Vue component per widget, which cannot survive
a lean build - which is why five plugins declared widgets pointing at
components nobody wrote. A plugin declares data and shape; core renders."""
from plugins.geenforce.plugin import GeEnforcePlugin
widget = GeEnforcePlugin().get_dashboard_widgets()[0]
assert 'component' not in widget
assert widget['render'] == 'exceptions'
assert widget['permission'] == 'geenforce.manage'
# Empty cards must shrink, or the board becomes wallpaper.
assert widget['empty'] == 'hide'
assert widget['map']['link'] == '/pcs/{computerid}'

View File

@@ -1,143 +0,0 @@
"""GE-Enforce DDL parity gate: models match the hand-written Alembic baseline.
The geenforce baseline (0001_geenforce_baseline.py) actually CREATES the plugin
tables (ADR-008 per-plugin chain), and it is hand-written - the models and the
migration can drift silently (a column added to a model but not the migration
ships a schema that upgrade() never builds, and vice versa). This guard builds
the schema two ways and diffs them column-by-column:
1. the SQLAlchemy models via metadata.create_all (desired shape), and
2. the migration's upgrade() run against a fresh engine (shipped shape).
Both go through the same SQLite dialect, so the reflected column types render
identically and the comparison stays low-flake. A drift (missing column, changed
type, changed nullability) fails here instead of on a real deploy insert.
"""
import importlib.util
from pathlib import Path
from sqlalchemy import create_engine, inspect
from alembic.migration import MigrationContext
from alembic.operations import Operations
_VERSIONS = (Path(__file__).resolve().parent.parent.parent / 'plugins' /
'geenforce' / 'migrations' / 'versions')
_BASELINE = _VERSIONS / '0001_geenforce_baseline.py'
def _geenforce_models():
from plugins.geenforce.models import manifest
return [
manifest.ManifestScope,
manifest.ManifestEntry,
manifest.ManifestEntryPcType,
manifest.ManifestEntryHostname,
manifest.ManifestEntryMachineNumber,
manifest.ManifestInUseCheck,
manifest.ManifestInUseCheckProcess,
manifest.ManifestPublishedVersion,
manifest.ManifestPayload,
manifest.ManifestEnforcementReport,
manifest.ManifestEnforcementResult,
manifest.PcTypeAlias,
]
def _model_schema(db, models):
"""Build the geenforce tables from the models; return an inspector."""
engine = create_engine('sqlite://')
db.metadata.create_all(engine, tables=[model.__table__ for model in models])
return inspect(engine)
def _migration_schema():
"""Run the WHOLE chain's upgrade() on a fresh engine; return columns.
The whole chain, not just the baseline. Reading only 0001 meant a column
added by a later revision read as model/migration drift even though the
migration for it existed - the check is "do the models match what the
migrations build", and that is the chain, not its first file.
Returns {tablename: {columnname: reflected-column-dict}}. Reflection happens
while the connection is open, so we materialize the dicts before it closes.
"""
migrations = []
for path in sorted(_VERSIONS.glob('[0-9]*.py')):
spec = importlib.util.spec_from_file_location(
'geenforce_migration_' + path.stem, str(path))
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
migrations.append(module)
engine = create_engine('sqlite://')
schema = {}
with engine.connect() as connection:
operations = Operations(MigrationContext.configure(connection))
operations._install_proxy()
try:
for migration in migrations:
migration.upgrade()
finally:
operations._remove_proxy()
connection.commit()
inspector = inspect(connection)
for table in inspector.get_table_names():
schema[table] = {column['name']: column
for column in inspector.get_columns(table)}
return schema
def _colkey(column):
"""Normalize a reflected column to the facets we gate on."""
return (str(column['type']), bool(column['nullable']))
def test_models_match_migration_baseline(app, db):
"""Every geenforce model table+column matches what the migration builds."""
with app.app_context():
models = _geenforce_models()
model_inspector = _model_schema(db, models)
migration_schema = _migration_schema()
drift = []
for model in models:
table = model.__tablename__
if table not in migration_schema:
drift.append(f'{table}: table missing from migration')
continue
modelcols = {column['name']: column
for column in model_inspector.get_columns(table)}
migrationcols = migration_schema[table]
missing = set(modelcols) - set(migrationcols)
extra = set(migrationcols) - set(modelcols)
for name in sorted(missing):
drift.append(f'{table}.{name}: in model, not in migration')
for name in sorted(extra):
drift.append(f'{table}.{name}: in migration, not in model')
for name in sorted(set(modelcols) & set(migrationcols)):
if _colkey(modelcols[name]) != _colkey(migrationcols[name]):
drift.append(
f'{table}.{name}: model {_colkey(modelcols[name])} != '
f'migration {_colkey(migrationcols[name])}')
assert not drift, 'model/migration DDL drift:\n' + '\n'.join(drift)
def test_curated_and_payload_columns_present_both_sides(app, db):
"""Spot-guard the two real-work columns: manifestentries.appid and
manifestpayloads.payloadsha256 exist in BOTH the models and the migration
(they were added for the app-link + inline-payload features)."""
with app.app_context():
models = _geenforce_models()
model_inspector = _model_schema(db, models)
migration_schema = _migration_schema()
for table, column in (('manifestentries', 'appid'),
('manifestpayloads', 'payloadsha256')):
modelcols = {c['name'] for c in model_inspector.get_columns(table)}
assert column in modelcols, f'{table}.{column} missing from models'
assert column in migration_schema[table], \
f'{table}.{column} missing from migration'

View File

@@ -1,299 +0,0 @@
"""Cover the gea-shopfloor-display authoring seed.
Running seed_display_scope must produce the display scope with the expected
entry count and types: four Registry drift-heal entries (Edge kiosk relaunch
policies) plus one inline PS1 dispatcher. Also checks the dispatcher payload is
stored inline and the display-type map drives the dispatcher script.
"""
from plugins.geenforce.models import (
ManifestScope, ManifestEntry, ManifestPayload,
)
from plugins.geenforce.seed_display_scope import (
seed_display_scope, build_display_manifest, build_dispatcher_script,
build_watchdog_script, build_forceedgeupdate_script,
DISPLAY_TYPE_TARGETS, SCOPE_NAME, DISPATCHER_FILENAME, ALWAYSON_FILENAME,
FORCE_EDGE_UPDATE_FILENAME, FORCE_EDGE_UPDATE_MARKER,
)
def test_seed_creates_display_scope(db):
summary = seed_display_scope()
scope = ManifestScope.query.filter_by(
scopename=SCOPE_NAME, phase='runtime').first()
assert scope is not None
# A plain runtime scope, not the common scope. Displays are self-sufficient
# and do NOT inherit common.
assert scope.iscommon is False
# The self-updating client module (File), then four Registry drift-heal
# entries, then four inline PS1 (dispatcher, watchdog, one-shot Edge
# force-update, always-on).
assert summary['entrycount'] == 9
assert summary['entrytypes'] == ['File', 'Registry', 'Registry', 'Registry',
'Registry', 'PS1', 'PS1', 'PS1', 'PS1']
def test_registry_entries_use_valuematches_detection(db):
seed_display_scope()
registry_entries = ManifestEntry.query.filter_by(
entrytype='Registry').order_by(ManifestEntry.sortorder).all()
assert len(registry_entries) == 4
regnames = {entry.regname for entry in registry_entries}
assert regnames == {
'RelaunchNotification', 'RelaunchNotificationPeriod',
'RelaunchHeadsUpPeriod', 'RelaunchWindow',
}
for entry in registry_entries:
assert entry.detectionmethod == 'ValueMatches'
assert entry.regpath == 'HKLM:\\SOFTWARE\\Policies\\Microsoft\\Edge'
# Detection targets the same value it writes, coerced to string.
assert entry.detectionname == entry.regname
assert entry.detectionvalue is not None
def test_dispatcher_is_inline_and_data_driven(db):
summary = seed_display_scope()
dispatcher = ManifestEntry.query.filter_by(
payloadref=DISPATCHER_FILENAME).one()
assert dispatcher.payloadsource == 'inline'
assert dispatcher.payloadref == DISPATCHER_FILENAME
assert dispatcher.payloadsha256 == summary['dispatchersha256']
payload = ManifestPayload.query.filter_by(
entryid=dispatcher.entryid).one()
scripttext = payload.payloadbytes.decode('utf-8')
# The data-driven map surfaces every display-type and its target route.
for display_type, route in DISPLAY_TYPE_TARGETS.items():
assert display_type in scripttext
assert route in scripttext
assert 'display-type.txt' in scripttext
# H3: SYSTEM writes an all-users Startup shortcut (CreateShortcut/.Save),
# rather than Start-Process-ing Edge (which would open invisibly in session 0).
assert 'CreateShortcut' in scripttext
assert '.Save()' in scripttext
assert 'Startup' in scripttext
# H4: real base URL, not the localhost placeholder.
assert 'localhost' not in scripttext
def test_alwayson_entry_is_inline(db):
summary = seed_display_scope()
alwayson = ManifestEntry.query.filter_by(
payloadref=ALWAYSON_FILENAME).one()
assert alwayson.entrytype == 'PS1'
assert alwayson.payloadsource == 'inline'
assert alwayson.detectionmethod == 'Always'
assert alwayson.payloadsha256 == summary['alwaysonsha256']
payload = ManifestPayload.query.filter_by(entryid=alwayson.entryid).one()
scripttext = payload.payloadbytes.decode('utf-8')
assert 'powercfg' in scripttext
assert 'monitor-timeout-ac 0' in scripttext
def test_seed_publish_freezes_a_version(db):
summary = seed_display_scope(publish=True)
assert summary['publishedversion'] == 1
scope = ManifestScope.query.filter_by(
scopename=SCOPE_NAME, phase='runtime').first()
published = scope.publishedversions.filter_by(iscurrent=True).first()
assert published is not None
assert published.versionnumber == 1
assert '"Version": "2.0"' in published.manifestjson
def test_seed_draft_is_idempotent(db):
first = seed_display_scope()
second = seed_display_scope()
# Re-running rebuilds the draft to the same shape (same scope, same count).
assert first['scopeid'] == second['scopeid']
assert first['entrycount'] == second['entrycount']
assert first['dispatchersha256'] == second['dispatchersha256']
assert first['alwaysonsha256'] == second['alwaysonsha256']
assert first['watchdogsha256'] == second['watchdogsha256']
assert first['forceupdatesha256'] == second['forceupdatesha256']
assert first['clientmodulesha256'] == second['clientmodulesha256']
entries = ManifestEntry.query.filter_by(scopeid=second['scopeid']).all()
assert len(entries) == 9
# Exactly four inline payloads (dispatcher + watchdog + force-update +
# always-on) exist
# after a rebuild, not more - a payload-count invariant. (The underlying
# re-publish FK crash only reproduces on MySQL, which enforces the
# manifestpayloads FK; it was verified there directly. SQLite does not
# enforce it.)
assert ManifestPayload.query.count() == 5
def test_build_manifest_has_no_smb_exe_payloads(db):
manifest = build_display_manifest()
# No entry ships an EXE/MSI/File payload from a share: the only payload is
# the inline dispatcher; everything else is a Registry policy heal.
for entry in manifest['Applications']:
assert entry.get('Installer') is None
# Every payload-bearing entry is delivered INLINE over HTTPS, never from
# a share: a display has no SFLD credentials at all.
if entry['Type'] in ('PS1', 'File'):
assert entry.get('PayloadSource') == 'inline'
else:
assert entry.get('Source') is None
def test_dispatcher_script_is_ascii():
# Plain ASCII only (no smart quotes / em-dashes) so the naming gate stays
# green and the on-PC script parses cleanly.
build_dispatcher_script().encode('ascii')
def test_watchdog_entry_is_present_and_inline(db):
"""The Startup shortcut runs once at logon, so an Edge update/crash leaves
the display dead until the next logon. The watchdog is what recovers it."""
manifest = build_display_manifest()
entry = next(a for a in manifest['Applications']
if a['Name'] == 'Display kiosk watchdog (relaunch Edge)')
assert entry['Type'] == 'PS1'
assert entry['PayloadSource'] == 'inline'
assert entry['DetectionMethod'] == 'Always'
def test_watchdog_script_registers_an_interactive_task():
"""A SYSTEM task would start Edge in session 0 where nobody can see it, so
the principal must be the interactive Users group."""
script = build_watchdog_script()
assert "New-ScheduledTaskPrincipal -GroupId 'S-1-5-32-545'" in script
assert 'ShopDB Kiosk Watchdog' in script
# The MaxValue repetition trap the kiosk installer documents. Checked
# against CODE lines only - the script comments the trap on purpose, so a
# naive substring test would fail on its own warning.
code = [line for line in script.splitlines()
if not line.lstrip().startswith('#')]
assert not any('RepetitionDuration' in line for line in code)
def test_watchdog_matches_on_the_command_line_not_the_image_name():
"""Edge spawns many msedge.exe children; only the parent carries --kiosk.
Testing 'is msedge running' would let a stray renderer mask a dead kiosk."""
script = build_watchdog_script()
assert "CommandLine -match '--kiosk'" in script
def test_watchdog_does_nothing_without_a_kiosk_shortcut():
"""It must never invent a browser launch on a PC that is not a kiosk."""
script = build_watchdog_script()
assert 'ShopDB Kiosk.lnk' in script
assert 'shopdb-kiosk-lastlaunch' in script # debounce stamp
def test_watchdog_requires_a_visible_window_not_just_a_process():
"""Edge can update and leave a --kiosk process behind with no window. The
first version tested the command line alone, so it saw 'kiosk is up' and
never relaunched - observed on a real display."""
script = build_watchdog_script()
assert 'MainWindowHandle' in script
def test_watchdog_kills_windowless_kiosk_before_relaunching():
"""Leaving the orphan would satisfy the next cycle's check again, and two
browsers would fight over the display."""
script = build_watchdog_script()
assert 'Stop-Process' in script
assert 'windowless' in script
def test_watchdog_refuses_to_run_as_system():
"""MainWindowHandle is session-scoped: SYSTEM reads 0 for a healthy kiosk,
so a mis-registered task would kill and relaunch the display every cycle."""
script = build_watchdog_script()
assert 'IsSystem' in script
def _forceupdate_entry():
return next(a for a in build_display_manifest()['Applications']
if a['Name'].startswith('Force pending Edge update'))
def test_forceupdate_is_a_one_shot_marker_entry(db):
"""MarkerFile is what makes it one-shot. With Always it would kill Edge on
every enforce cycle forever, which is a kiosk that never stays up."""
entry = _forceupdate_entry()
assert entry['Type'] == 'PS1'
assert entry['PayloadSource'] == 'inline'
assert entry['DetectionMethod'] == 'MarkerFile'
# The engine only writes the marker when DetectionPath is set; without it
# the entry silently degrades to running every cycle.
assert entry['DetectionPath'] == FORCE_EDGE_UPDATE_MARKER
def test_forceupdate_runs_after_the_watchdog_is_registered(db):
"""It stops Edge and relies on the watchdog to bring the kiosk back, so on a
display seeing both for the first time the watchdog must come first."""
names = [a['Name'] for a in build_display_manifest()['Applications']]
watchdog = names.index('Display kiosk watchdog (relaunch Edge)')
forceupdate = next(i for i, n in enumerate(names)
if n.startswith('Force pending Edge update'))
assert watchdog < forceupdate
def test_forceupdate_payload_is_stored(db):
"""An entry whose payload never stored would enforce nothing at all."""
summary = seed_display_scope()
payload = ManifestPayload.query.filter_by(
filename=FORCE_EDGE_UPDATE_FILENAME).one()
assert payload.payloadsha256 == summary['forceupdatesha256']
def test_forceupdate_script_exits_zero_so_the_marker_is_written():
"""The engine writes the marker only on a 0 exit. A script that failed on
'no Edge running' would never mark done and would re-kill Edge forever."""
script = build_forceedgeupdate_script()
assert script.rstrip().endswith('exit 0')
assert 'exit 1' not in script
def test_forceupdate_script_does_not_launch_the_browser_itself():
"""SYSTEM runs in session 0, where a launched browser is invisible. Relaunch
is the watchdog's job, from the Startup shortcut."""
script = build_forceedgeupdate_script()
assert 'msedge' in script
assert 'Start-Process' not in script.split('# Stop every Edge process')[1]
def test_forceupdate_script_is_ascii():
build_forceedgeupdate_script().encode('ascii')
def _client_entry():
return next(a for a in build_display_manifest()['Applications']
if a['Name'].startswith('GE-Enforce client module'))
def test_client_module_ships_with_a_hash_of_the_bytes_it_ships(db):
"""The detection hash must match the shipped payload exactly. A mismatch
means every kiosk rewrites the module on every cycle, forever."""
import hashlib
from plugins.geenforce.seed_display_scope import read_client_module
entry = _client_entry()
assert entry['DetectionMethod'] == 'Hash'
assert entry['DetectionValue'] == hashlib.sha256(read_client_module()).hexdigest()
# Detection must look at where the file LANDS, not where it came from.
assert entry['DetectionPath'] == entry['Destination']
def test_client_module_lands_where_the_installer_puts_it(db):
"""Bootstrap and self-update must write the SAME path, or a kiosk ends up
running the installer's copy while the enforced one sits beside it."""
entry = _client_entry()
assert entry['Destination'].endswith('\\ShopdbEnforceClient.psm1')
assert 'GE-Enforce' in entry['Destination']
def test_client_module_is_first_so_a_stale_client_refreshes_first(db):
names = [a['Name'] for a in build_display_manifest()['Applications']]
assert names[0].startswith('GE-Enforce client module')

View File

@@ -1,123 +0,0 @@
"""GE-Enforce export-to-share: config the share root, publish, write to share.
This is the Milestone 1 loop - author + publish in shopdb, then export the
published JSON to the on-share manifest with a history backup, engine untouched.
"""
import json
import os
from plugins.geenforce import service
def _scope_with_entry(app, name='gea-shopfloor-cmm'):
with app.app_context():
service.replace_scope_draft(name, 'runtime', {
'Version': '2.6',
'Applications': [{'Name': 'Alpha', 'Type': 'MSI',
'Installer': 'apps/a.msi'}]})
service.publish_scope(name, 'runtime', notes='v1')
scope = service.ManifestScope.query.filter_by(scopename=name).first()
scopeid = scope.scopeid
service.db.session.commit()
return scopeid
def test_export_requires_config(client, db, app, auth_headers):
scopeid = _scope_with_entry(app)
resp = client.post(f'/api/geenforce/scopes/{scopeid}/export-share',
headers=auth_headers)
assert resp.status_code == 400 # no share root configured
def test_config_roundtrip_and_export(client, db, app, auth_headers, tmp_path):
scopeid = _scope_with_entry(app)
# Configure the share root.
put = client.put('/api/geenforce/config',
json={'shareroot': str(tmp_path)}, headers=auth_headers)
assert put.status_code == 200
got = client.get('/api/geenforce/config', headers=auth_headers)
assert got.get_json()['data']['shareroot'] == str(tmp_path)
# Export writes the published JSON to <shareroot>/<scope>/manifest.json.
resp = client.post(f'/api/geenforce/scopes/{scopeid}/export-share',
headers=auth_headers)
assert resp.status_code == 200, resp.get_json()
path = resp.get_json()['data']['path']
assert os.path.isfile(path)
written = json.load(open(path))
assert [e['Name'] for e in written['Applications']] == ['Alpha']
def test_export_backs_up_existing(client, db, app, auth_headers, tmp_path):
scopeid = _scope_with_entry(app)
client.put('/api/geenforce/config', json={'shareroot': str(tmp_path)},
headers=auth_headers)
# First export creates the file.
client.post(f'/api/geenforce/scopes/{scopeid}/export-share',
headers=auth_headers)
# Second export backs the old file into _meta/history.
client.post(f'/api/geenforce/scopes/{scopeid}/export-share',
headers=auth_headers)
historydir = tmp_path / '_meta' / 'history'
assert historydir.is_dir()
assert any(historydir.iterdir())
# --- a scope name is a directory on the share --------------------------------
#
# create_scope checked only that the name was non-empty, and the export did
# os.path.join(shareroot, scopename, 'manifest.json'). So "../escape" wrote one
# level above the share root and "/tmp/anywhere" wrote wherever it liked. The
# exposure is the permission split: geenforce.manage creates the scope,
# geenforce.publish writes the file, so a manage-only author could aim a
# publisher's write at any path that process can reach.
import pytest
@pytest.mark.parametrize('name', [
'../escape',
'../../escape',
'/tmp/absolute',
'sub/dir',
'back\\slash',
'..',
'.',
])
def test_a_traversing_scope_name_is_refused_at_creation(client, db,
auth_headers, name):
created = client.post('/api/geenforce/scopes',
json={'scopename': name, 'phase': 'runtime'},
headers=auth_headers)
assert created.status_code == 400, (name, created.get_json())
assert 'scopename' in created.get_json()['data']['error']['message']
def test_a_normal_scope_name_is_still_accepted(client, db, auth_headers):
"""The names sites actually use must keep working, or the guard is worse
than the hole."""
for name in ('common', 'display', 'gea-shopfloor-cmm', 'bay.3105_a'):
created = client.post('/api/geenforce/scopes',
json={'scopename': name, 'phase': 'runtime'},
headers=auth_headers)
assert created.status_code in (200, 201), (name, created.get_json())
def test_the_export_refuses_a_traversing_name_even_from_the_database(
app, db, tmp_path):
"""Rows created before the validation existed are still out there, and the
export is the line that actually writes."""
with app.app_context():
service.replace_scope_draft('../legacy-escape', 'runtime',
{'Version': '2.6', 'Applications': []})
service.publish_scope('../legacy-escape', 'runtime', notes='v1')
service.db.session.commit()
with pytest.raises(ValueError) as refused:
service.export_scope_to_share('../legacy-escape', 'runtime',
str(tmp_path))
assert 'safe directory name' in str(refused.value)
assert not (tmp_path.parent / 'legacy-escape').exists()

View File

@@ -1,91 +0,0 @@
"""GE-Enforce HTTPS payload delivery: content-addressed blob store + the
client-facing GET /api/geenforce/payload/<sha256> endpoint.
Lets share-less (Intune/local-account) PCs pull installers the manifest
references over HTTPS instead of SMB. Auth = a geenforce.fetch service token
(same as /manifest). The sha256 IS the integrity guarantee (client re-verifies).
"""
import hashlib
from plugins.geenforce import service
from plugins.geenforce.models import ManifestBlob
def _fetch_key(client, auth_headers):
resp = client.post('/api/apitokens',
json={'name': 'svc', 'scopes': ['geenforce.fetch']},
headers=auth_headers)
assert resp.status_code == 201, resp.get_json()
return {'X-API-Key': resp.get_json()['data']['secret']}
def _store(app, raw, filename='setup.exe'):
with app.app_context():
sha = service.store_blob(raw, filename, 'application/octet-stream')
service.db.session.commit()
return sha
def test_store_blob_dedups_and_registers(app, db):
raw = b'MZ fake installer bytes'
sha1 = _store(app, raw)
sha2 = _store(app, raw) # same content
assert sha1 == sha2 == hashlib.sha256(raw).hexdigest()
with app.app_context():
assert ManifestBlob.query.count() == 1
blob = service.db.session.get(ManifestBlob, sha1)
assert blob.sizebytes == len(raw)
def test_download_returns_exact_bytes(client, app, auth_headers):
raw = b'\x00\x01 big binary installer \xff\xfe'
sha = _store(app, raw, 'kiosk.exe')
headers = _fetch_key(client, auth_headers)
resp = client.get(f'/api/geenforce/payload/{sha}', headers=headers)
assert resp.status_code == 200
assert resp.data == raw
assert resp.headers['ETag'] == f'"{sha}"'
assert 'kiosk.exe' in resp.headers.get('Content-Disposition', '')
def test_download_requires_fetch_token(client, app, auth_headers):
sha = _store(app, b'data')
# no key
assert client.get(f'/api/geenforce/payload/{sha}').status_code == 401
def test_download_unknown_hash_404(client, auth_headers):
headers = _fetch_key(client, auth_headers)
sha = 'a' * 64
assert client.get(f'/api/geenforce/payload/{sha}', headers=headers).status_code == 404
def test_download_bad_hash_400(client, auth_headers):
headers = _fetch_key(client, auth_headers)
assert client.get('/api/geenforce/payload/not-a-hash', headers=headers).status_code == 400
def test_etag_304(client, app, auth_headers):
sha = _store(app, b'cacheable')
headers = _fetch_key(client, auth_headers)
resp = client.get(f'/api/geenforce/payload/{sha}',
headers={**headers, 'If-None-Match': f'"{sha}"'})
assert resp.status_code == 304
def test_serializer_emits_payload_transport_for_http_only(app):
"""http/inline entries emit PayloadSource + PayloadSha256; smb stays bare."""
from plugins.geenforce.serializer import entry_to_dict
from plugins.geenforce.models import ManifestEntry
smb = ManifestEntry(name='a', entrytype='EXE', payloadsource='smb')
assert 'PayloadSource' not in entry_to_dict(smb)
http = ManifestEntry(name='b', entrytype='EXE', payloadsource='http',
payloadsha256='f' * 64, payloadref='kiosk.exe')
out = entry_to_dict(http)
assert out['PayloadSource'] == 'http'
assert out['PayloadSha256'] == 'f' * 64
assert out['PayloadRef'] == 'kiosk.exe'

View File

@@ -1,195 +0,0 @@
"""GE-Enforce first slice: import a scope, publish it, serve it to a client.
Exercises the vertical the plan calls the first slice (via a small synthetic
scope rather than the real gea-shopfloor-cmm): draft import -> publish ->
GET /api/geenforce/manifest with a geenforce.fetch service token; ETag/304;
draft edits never change the served bytes; publish + rollback change them.
"""
import json
import pytest
from plugins.geenforce.models import ManifestScope
from plugins.geenforce import service
SCOPE = {
'Version': '2.6',
'Applications': [
{'Name': 'Alpha', 'Type': 'MSI', 'Installer': 'apps/alpha.msi',
'DetectionMethod': 'Registry', 'DetectionPath': 'HKLM:\\SOFTWARE\\Alpha'},
{'Name': 'Beta', 'Type': 'PS1', 'Script': 'scripts/beta.ps1',
'DetectionMethod': 'Always'},
],
}
def _seed_and_publish(app, scopename='gea-shopfloor-cmm', manifest=None):
with app.app_context():
service.replace_scope_draft(scopename, 'runtime', manifest or SCOPE)
service.publish_scope(scopename, 'runtime', notes='initial')
service.db.session.commit()
def _mint_fetch_token(client, auth_headers):
resp = client.post('/api/apitokens',
json={'name': 'geenforce svc', 'scopes': ['geenforce.fetch']},
headers=auth_headers)
assert resp.status_code == 201, resp.get_json()
return resp.get_json()['data']['secret']
def test_import_publish_serve(client, db, app, auth_headers):
_seed_and_publish(app)
secret = _mint_fetch_token(client, auth_headers)
resp = client.get('/api/geenforce/manifest?pctype=gea-shopfloor-cmm',
headers={'X-API-Key': secret})
assert resp.status_code == 200, resp.data
body = json.loads(resp.data)
assert [e['Name'] for e in body['Applications']] == ['Alpha', 'Beta']
assert resp.headers.get('ETag')
assert resp.headers.get('X-Manifest-Version') == '1'
def test_etag_304(client, db, app, auth_headers):
_seed_and_publish(app)
secret = _mint_fetch_token(client, auth_headers)
first = client.get('/api/geenforce/manifest?pctype=gea-shopfloor-cmm',
headers={'X-API-Key': secret})
etag = first.headers['ETag']
again = client.get('/api/geenforce/manifest?pctype=gea-shopfloor-cmm',
headers={'X-API-Key': secret, 'If-None-Match': etag})
assert again.status_code == 304
def test_draft_edit_does_not_change_served_bytes(client, db, app, auth_headers):
_seed_and_publish(app)
secret = _mint_fetch_token(client, auth_headers)
before = client.get('/api/geenforce/manifest?pctype=gea-shopfloor-cmm',
headers={'X-API-Key': secret}).data
# Edit the DRAFT (add an entry) but do NOT publish.
changed = {'Version': '2.6', 'Applications': SCOPE['Applications'] + [
{'Name': 'Gamma', 'Type': 'PS1', 'Script': 'scripts/gamma.ps1'}]}
with app.app_context():
service.replace_scope_draft('gea-shopfloor-cmm', 'runtime', changed)
service.db.session.commit()
after = client.get('/api/geenforce/manifest?pctype=gea-shopfloor-cmm',
headers={'X-API-Key': secret}).data
assert before == after # served bytes come from the published snapshot only
def test_publish_then_rollback(client, db, app, auth_headers):
_seed_and_publish(app)
secret = _mint_fetch_token(client, auth_headers)
v1 = client.get('/api/geenforce/manifest?pctype=gea-shopfloor-cmm',
headers={'X-API-Key': secret}).data
changed = {'Version': '2.6', 'Applications': SCOPE['Applications'] + [
{'Name': 'Gamma', 'Type': 'PS1', 'Script': 'scripts/gamma.ps1'}]}
with app.app_context():
service.replace_scope_draft('gea-shopfloor-cmm', 'runtime', changed)
service.publish_scope('gea-shopfloor-cmm', 'runtime', notes='add gamma')
service.db.session.commit()
v2 = client.get('/api/geenforce/manifest?pctype=gea-shopfloor-cmm',
headers={'X-API-Key': secret})
assert v2.headers['X-Manifest-Version'] == '2'
assert b'Gamma' in v2.data
with app.app_context():
service.rollback_scope('gea-shopfloor-cmm', 'runtime', 1)
service.db.session.commit()
rolled = client.get('/api/geenforce/manifest?pctype=gea-shopfloor-cmm',
headers={'X-API-Key': secret})
assert rolled.data == v1 # byte-identical: published snapshots are frozen text
def test_unauthenticated_rejected(client, db, app):
_seed_and_publish(app)
resp = client.get('/api/geenforce/manifest?pctype=gea-shopfloor-cmm')
assert resp.status_code == 401
def _set_allowlist(app, value):
from shopdb.api import Setting
with app.app_context():
Setting.set('geenforce_allowed_cidrs', value, 'string', 'geenforce')
service.db.session.commit()
def test_ip_allowlist_allows_without_token(client, db, app):
# An allowlisted caller reaches the manifest with NO token (vault trust).
_seed_and_publish(app)
_set_allowlist(app, '127.0.0.0/8, 192.0.2.0/24') # test client is 127.0.0.1
resp = client.get('/api/geenforce/manifest?pctype=gea-shopfloor-cmm')
assert resp.status_code == 200, resp.data
assert b'Alpha' in resp.data
def test_ip_not_in_allowlist_still_rejected(client, db, app):
# A caller outside the allowlist and with no token is refused (fail-closed).
_seed_and_publish(app)
_set_allowlist(app, '10.0.0.0/8') # test client 127.0.0.1 is NOT in 10/8
resp = client.get('/api/geenforce/manifest?pctype=gea-shopfloor-cmm')
assert resp.status_code == 401
def test_empty_allowlist_keeps_token_required(client, db, app):
# Empty/unset allowlist = disabled; token stays the only path (back-compat).
_seed_and_publish(app)
_set_allowlist(app, '')
resp = client.get('/api/geenforce/manifest?pctype=gea-shopfloor-cmm')
assert resp.status_code == 401
def test_spoofed_forwarded_for_does_not_bypass_allowlist(client, db, app):
# SECURITY: the allowlist uses remote_addr, not X-Forwarded-For. A caller
# whose real IP (127.0.0.1) is NOT allowlisted must NOT gain token-less access
# by forging X-Forwarded-For to an allowlisted address.
_seed_and_publish(app)
_set_allowlist(app, '192.0.2.0/24') # test client 127.0.0.1 is NOT in it
resp = client.get('/api/geenforce/manifest?pctype=gea-shopfloor-cmm',
headers={'X-Forwarded-For': '192.0.2.10'})
assert resp.status_code == 401, 'spoofed X-Forwarded-For bypassed the allowlist'
def test_wrong_scope_rejected(client, db, app, auth_headers):
_seed_and_publish(app)
resp = client.post('/api/apitokens',
json={'name': 'wrong', 'scopes': ['collector.ingest']},
headers=auth_headers)
secret = resp.get_json()['data']['secret']
manifest = client.get('/api/geenforce/manifest?pctype=gea-shopfloor-cmm',
headers={'X-API-Key': secret})
assert manifest.status_code == 401
def test_unpublished_scope_404(client, db, app, auth_headers):
with app.app_context():
service.replace_scope_draft('gea-shopfloor-keyence', 'runtime', SCOPE)
service.db.session.commit() # draft only, never published
secret = _mint_fetch_token(client, auth_headers)
resp = client.get('/api/geenforce/manifest?pctype=gea-shopfloor-keyence',
headers={'X-API-Key': secret})
assert resp.status_code == 404
def test_admin_list_and_preview(client, db, app, auth_headers):
_seed_and_publish(app)
listing = client.get('/api/geenforce/scopes', headers=auth_headers)
assert listing.status_code == 200
scopes = listing.get_json()['data']
cmm = next(s for s in scopes if s['scopename'] == 'gea-shopfloor-cmm')
assert cmm['entrycount'] == 2
assert cmm['publishedversion'] == 1
preview = client.get(f"/api/geenforce/scopes/{cmm['scopeid']}/preview",
headers=auth_headers)
assert preview.status_code == 200
assert [e['Name'] for e in preview.get_json()['data']['manifest']['Applications']] \
== ['Alpha', 'Beta']

View File

@@ -1,125 +0,0 @@
"""GE-Enforce inline payloads: upload small scripts/configs into shopdb.
An entry can carry one inline payload (<= 1 MB) stored as ManifestPayload bytes.
Upload computes a sha256, sets the entry's payloadsource='inline' + payloadref,
and enforces one payload row per entry (re-upload replaces). GET returns the
raw bytes. Upload needs geenforce.publish; GET needs geenforce.manage. appid and
the payload metadata are shopdb-only and never enter the served manifest JSON.
"""
import hashlib
import io
from plugins.geenforce.models import ManifestPayload
def _create_scope(client, auth_headers, name='gea-shopfloor-cmm'):
resp = client.post('/api/geenforce/scopes',
json={'scopename': name, 'phase': 'runtime'},
headers=auth_headers)
assert resp.status_code == 201, resp.get_json()
return resp.get_json()['data']['scopeid']
def _add_entry(client, auth_headers, scopeid, name='eDNC install'):
resp = client.post(f'/api/geenforce/scopes/{scopeid}/entries',
json={'Name': name, 'Type': 'PS1',
'Script': 'scripts/x.ps1'},
headers=auth_headers)
assert resp.status_code == 201, resp.get_json()
return resp.get_json()['data']['entryid']
def _upload(client, headers, entryid, content, filename='config.reg',
content_type='multipart/form-data'):
return client.post(
f'/api/geenforce/entries/{entryid}/payload',
data={'file': (io.BytesIO(content), filename)},
content_type=content_type, headers=headers)
def test_upload_stores_payload_and_links_entry(client, db, auth_headers):
scopeid = _create_scope(client, auth_headers)
entryid = _add_entry(client, auth_headers, scopeid)
content = b'Windows Registry Editor Version 5.00\r\n'
resp = _upload(client, auth_headers, entryid, content)
assert resp.status_code == 201, resp.get_json()
data = resp.get_json()['data']
assert data['payloadsource'] == 'inline'
assert data['payloadref'] == 'config.reg'
assert data['payloadsha256'] == hashlib.sha256(content).hexdigest()
assert data['haspayload'] is True
rows = ManifestPayload.query.filter_by(entryid=entryid).all()
assert len(rows) == 1
assert rows[0].payloadbytes == content
def test_reupload_replaces_single_row(client, db, auth_headers):
scopeid = _create_scope(client, auth_headers)
entryid = _add_entry(client, auth_headers, scopeid)
_upload(client, auth_headers, entryid, b'first')
newcontent = b'second version'
resp = _upload(client, auth_headers, entryid, newcontent)
assert resp.status_code == 201
rows = ManifestPayload.query.filter_by(entryid=entryid).all()
assert len(rows) == 1
assert rows[0].payloadbytes == newcontent
assert rows[0].payloadsha256 == hashlib.sha256(newcontent).hexdigest()
def test_oversized_payload_rejected(client, db, auth_headers):
scopeid = _create_scope(client, auth_headers)
entryid = _add_entry(client, auth_headers, scopeid)
toobig = b'x' * (1024 * 1024 + 1)
resp = _upload(client, auth_headers, entryid, toobig)
assert resp.status_code == 400
assert ManifestPayload.query.filter_by(entryid=entryid).count() == 0
def test_empty_and_missing_file_rejected(client, db, auth_headers):
scopeid = _create_scope(client, auth_headers)
entryid = _add_entry(client, auth_headers, scopeid)
empty = _upload(client, auth_headers, entryid, b'')
assert empty.status_code == 400
nofile = client.post(f'/api/geenforce/entries/{entryid}/payload',
data={}, content_type='multipart/form-data',
headers=auth_headers)
assert nofile.status_code == 400
def test_download_returns_exact_bytes(client, db, auth_headers):
scopeid = _create_scope(client, auth_headers)
entryid = _add_entry(client, auth_headers, scopeid)
content = b'\x00\x01binary payload\xff'
_upload(client, auth_headers, entryid, content, filename='blob.bin')
resp = client.get(f'/api/geenforce/entries/{entryid}/payload',
headers=auth_headers)
assert resp.status_code == 200
assert resp.data == content
assert 'blob.bin' in resp.headers['Content-Disposition']
def test_download_404_when_no_payload(client, db, auth_headers):
scopeid = _create_scope(client, auth_headers)
entryid = _add_entry(client, auth_headers, scopeid)
resp = client.get(f'/api/geenforce/entries/{entryid}/payload',
headers=auth_headers)
assert resp.status_code == 404
def test_upload_404_unknown_entry(client, db, auth_headers):
resp = _upload(client, auth_headers, 999999, b'data')
assert resp.status_code == 404
def test_upload_requires_publish_permission(client, db, auth_headers,
member_headers):
scopeid = _create_scope(client, auth_headers)
entryid = _add_entry(client, auth_headers, scopeid)
# member (no permissions) cannot upload.
resp = _upload(client, member_headers, entryid, b'data')
assert resp.status_code == 403

View File

@@ -1,161 +0,0 @@
"""GE-Enforce payload-download hardening: rate limit + served-size ceiling.
GET /api/geenforce/payload/<sha256> is reachable with only a read-only
geenforce.fetch token, so a leaked display token must not be able to hammer it
or pull unbounded bytes. These tests lock the two bounds added to the route:
a per-IP fixed-window rate limit (same cache extension as the login limiter)
and a size cap that refuses (413) a blob/inline payload over the configured max.
"""
import hashlib
import io
from contextlib import contextmanager
from plugins.geenforce import service
from plugins.geenforce.models import ManifestBlob
def _fetch_key(client, auth_headers):
resp = client.post('/api/apitokens',
json={'name': 'svc', 'scopes': ['geenforce.fetch']},
headers=auth_headers)
assert resp.status_code == 201, resp.get_json()
return {'X-API-Key': resp.get_json()['data']['secret']}
def _store(app, raw, filename='setup.exe'):
with app.app_context():
sha = service.store_blob(raw, filename, 'application/octet-stream')
service.db.session.commit()
return sha
@contextmanager
def _config(app, **overrides):
"""Temporarily override app.config keys (the app fixture is session-scoped,
so every key is restored to its prior value/absence on exit)."""
missing = object()
saved = {key: app.config.get(key, missing) for key in overrides}
app.config.update(overrides)
try:
yield
finally:
for key, value in saved.items():
if value is missing:
app.config.pop(key, None)
else:
app.config[key] = value
def test_blob_within_cap_is_served(client, app, auth_headers):
raw = b'x' * 64
sha = _store(app, raw, 'ok.exe')
headers = _fetch_key(client, auth_headers)
with _config(app, GEENFORCE_PAYLOAD_MAX_BYTES=1024):
resp = client.get(f'/api/geenforce/payload/{sha}', headers=headers)
assert resp.status_code == 200
assert resp.data == raw
def test_oversized_blob_refused_413(client, app, auth_headers):
raw = b'x' * 64
sha = _store(app, raw, 'big.exe')
headers = _fetch_key(client, auth_headers)
with _config(app, GEENFORCE_PAYLOAD_MAX_BYTES=16):
resp = client.get(f'/api/geenforce/payload/{sha}', headers=headers)
assert resp.status_code == 413
# The blob registry row still exists; the cap gates delivery, not storage.
with app.app_context():
assert service.db.session.get(ManifestBlob, sha) is not None
def test_oversized_inline_payload_refused_413(client, app, db, auth_headers):
scopeid = client.post(
'/api/geenforce/scopes',
json={'scopename': 'gea-shopfloor-cmm', 'phase': 'runtime'},
headers=auth_headers).get_json()['data']['scopeid']
entryid = client.post(
f'/api/geenforce/scopes/{scopeid}/entries',
json={'Name': 'eDNC config', 'Type': 'PS1', 'Script': 's.ps1'},
headers=auth_headers).get_json()['data']['entryid']
content = b'inline config bytes here'
up = client.post(f'/api/geenforce/entries/{entryid}/payload',
data={'file': (io.BytesIO(content), 'config.reg')},
content_type='multipart/form-data', headers=auth_headers)
assert up.status_code == 201, up.get_json()
sha = hashlib.sha256(content).hexdigest()
headers = _fetch_key(client, auth_headers)
with _config(app, GEENFORCE_PAYLOAD_MAX_BYTES=4):
resp = client.get(f'/api/geenforce/payload/{sha}', headers=headers)
assert resp.status_code == 413
def test_payload_download_is_rate_limited(client, app, auth_headers):
sha = _store(app, b'small blob')
key = _fetch_key(client, auth_headers)
# A unique caller IP isolates this bucket from other tests' shared counter.
headers = {**key, 'X-Forwarded-For': '203.0.113.201'}
with _config(app, GEENFORCE_PAYLOAD_RATELIMIT_MAX=3,
GEENFORCE_PAYLOAD_RATELIMIT_WINDOW_SECONDS=300):
for _ in range(3):
ok = client.get(f'/api/geenforce/payload/{sha}', headers=headers)
assert ok.status_code == 200, ok.get_json()
# The 4th request in the same window is over budget.
over = client.get(f'/api/geenforce/payload/{sha}', headers=headers)
assert over.status_code == 429
def test_rate_limit_is_per_ip(client, app, auth_headers):
"""A second caller has its own budget: one IP being throttled must not
throttle everyone.
Callers are distinguished by REMOTE_ADDR, the peer that actually connected,
which is what ProxyFix fills from X-Forwarded-For when a proxy is really in
front of this instance.
"""
sha = _store(app, b'per-ip blob')
key = _fetch_key(client, auth_headers)
hot = {'REMOTE_ADDR': '203.0.113.202'}
cool = {'REMOTE_ADDR': '203.0.113.203'}
with _config(app, GEENFORCE_PAYLOAD_RATELIMIT_MAX=1,
GEENFORCE_PAYLOAD_RATELIMIT_WINDOW_SECONDS=300):
assert client.get(f'/api/geenforce/payload/{sha}',
headers=key, environ_base=hot).status_code == 200
assert client.get(f'/api/geenforce/payload/{sha}',
headers=key, environ_base=hot).status_code == 429
# Fresh IP still gets its first request through.
assert client.get(f'/api/geenforce/payload/{sha}',
headers=key, environ_base=cool).status_code == 200
def test_rate_limit_cannot_be_reset_by_forging_a_forwarded_header(
client, app, auth_headers):
"""The budget follows the connection, not a header the caller writes.
The limiter used to key on the first X-Forwarded-For hop, so rotating that
header per request bought an unbounded budget and the limit stopped being
a limit.
"""
sha = _store(app, b'forged header blob')
key = _fetch_key(client, auth_headers)
caller = {'REMOTE_ADDR': '203.0.113.210'}
with _config(app, GEENFORCE_PAYLOAD_RATELIMIT_MAX=1,
GEENFORCE_PAYLOAD_RATELIMIT_WINDOW_SECONDS=300):
assert client.get(f'/api/geenforce/payload/{sha}', headers=key,
environ_base=caller).status_code == 200
for hop in ('198.51.100.1', '198.51.100.2', '198.51.100.3'):
spoofed = {**key, 'X-Forwarded-For': hop}
assert client.get(f'/api/geenforce/payload/{sha}', headers=spoofed,
environ_base=caller).status_code == 429
def test_rate_limit_can_be_disabled(client, app, auth_headers):
sha = _store(app, b'unthrottled blob')
key = _fetch_key(client, auth_headers)
headers = {**key, 'X-Forwarded-For': '203.0.113.204'}
with _config(app, GEENFORCE_PAYLOAD_RATELIMIT_ENABLED=False,
GEENFORCE_PAYLOAD_RATELIMIT_MAX=1):
for _ in range(5):
assert client.get(f'/api/geenforce/payload/{sha}',
headers=headers).status_code == 200

View File

@@ -1,318 +0,0 @@
"""Publishing must not outrun the enforcer lib deployed on the fleet.
THE FAILURE THIS PREVENTS: the engine treats a minor-newer manifest as backward
compatible and carries on. That is true for additions that WIDEN behaviour (an
old lib skips a Type it does not know) and false for one that NARROWS it. The
_CmmVersion filter shipped in lib 2.6 as a minor bump, so a PC on lib 2.5 does
not recognise the field, reads every gated entry as unfiltered, and installs
EVERY PC-DMIS version it cannot detect - on every CMM, within one cycle.
The share runbook says "push the lib first". A runbook is not a control.
"""
from datetime import datetime, timezone
import pytest
from plugins.geenforce import service
from plugins.geenforce.models import ManifestScope, ManifestEnforcementReport
from shopdb.api import db
def _scope(manifestversion='2.6', scopename='gea-shopfloor-cmm'):
scope = ManifestScope(scopename=scopename, phase='runtime',
manifestversion=manifestversion)
db.session.add(scope)
db.session.flush()
return scope
def _report(hostname, enforcerversion, scopename='gea-shopfloor-cmm'):
report = ManifestEnforcementReport(
hostname=hostname, scopename=scopename, phase='runtime',
enforcerversion=enforcerversion, receivedat=datetime.now(timezone.utc).replace(tzinfo=None),
iscurrent=True)
db.session.add(report)
db.session.flush()
return report
# -- version parsing ---------------------------------------------------------
@pytest.mark.parametrize('text,expected', [
('2.6', (2, 6)),
('2.10', (2, 10)), # 10 > 6, not string-ordered
('3', (3, 0)),
(' 2.6 ', (2, 6)),
(None, None),
('', None),
('unknown', None),
('2.x', None),
])
def test_parse_libversion(text, expected):
assert service.parse_libversion(text) == expected
def test_minor_versions_compare_numerically_not_as_strings():
"""'2.10' vs '2.9' is the classic string-compare trap: as text '2.10' sorts
BELOW '2.9' and a current lib would look out of date."""
assert service.parse_libversion('2.10') > service.parse_libversion('2.9')
# -- the gate ----------------------------------------------------------------
def test_publish_refused_when_a_cmm_runs_an_older_lib(db):
"""The real scenario: cmm manifest 2.6, one bay still on 2.5."""
_scope('2.6')
_report('CMMBAY01', '2.6')
_report('CMMBAY02', '2.5')
with pytest.raises(service.LibVersionTooOldError) as caught:
service.publish_scope('gea-shopfloor-cmm', 'runtime')
error = caught.value
assert error.hosts == ['CMMBAY02']
assert error.required == '2.6'
assert 'CMMBAY02' in str(error)
# The message has to say what to do, not just that it said no.
assert 'lib' in str(error).lower()
def test_publish_allowed_when_the_whole_fleet_is_current(db):
_scope('2.6')
_report('CMMBAY01', '2.6')
_report('CMMBAY02', '2.7')
assert service.publish_scope('gea-shopfloor-cmm', 'runtime') == 1
def test_a_missing_enforcerversion_counts_as_behind(db):
"""The field arrived with the summary-emitting engine, so its absence means
an old lib. Treating unknown as safe is how this fails open."""
_scope('2.6')
_report('CMMBAY01', None)
with pytest.raises(service.LibVersionTooOldError) as caught:
service.publish_scope('gea-shopfloor-cmm', 'runtime')
assert caught.value.hosts == ['CMMBAY01']
def test_an_unparseable_enforcerversion_counts_as_behind(db):
_scope('2.6')
_report('CMMBAY01', 'garbage')
with pytest.raises(service.LibVersionTooOldError):
service.publish_scope('gea-shopfloor-cmm', 'runtime')
def test_force_publishes_anyway(db):
"""The gate is a guard rail, not a wall - someone who knows why can pass."""
_scope('2.6')
_report('CMMBAY02', '2.5')
assert service.publish_scope(
'gea-shopfloor-cmm', 'runtime', force=True) == 1
def test_a_scope_nobody_reports_for_still_publishes(db):
"""No reports = no floor to compare. A fresh site must not be unable to
publish anything at all."""
_scope('2.6')
assert service.publish_scope('gea-shopfloor-cmm', 'runtime') == 1
def test_other_scopes_reports_do_not_block_this_one(db):
"""A PC on an old lib reporting for 'common' says nothing about whether the
cmm scope may publish - only PCs that receive THIS scope matter."""
_scope('2.6')
_report('OLDPC', '2.0', scopename='common')
assert service.publish_scope('gea-shopfloor-cmm', 'runtime') == 1
def test_superseded_reports_are_ignored(db):
"""Only the current report per host counts; a stale row from before a lib
upgrade must not block forever."""
_scope('2.6')
old = _report('CMMBAY01', '2.5')
old.iscurrent = False
_report('CMMBAY01', '2.6')
assert service.publish_scope('gea-shopfloor-cmm', 'runtime') == 1
def test_an_unversioned_manifest_does_not_block(db):
"""A scope with no parseable manifestversion has nothing to require."""
_scope('legacy')
_report('CMMBAY01', '2.5')
assert service.publish_scope('gea-shopfloor-cmm', 'runtime') == 1
def test_hosts_below_libversion_reports_the_floor(db):
_scope('2.6')
_report('A', '2.5')
_report('B', '2.3')
_report('C', '2.6')
hosts, floor = service.hosts_below_libversion(
'gea-shopfloor-cmm', 'runtime', '2.6')
assert hosts == ['A', 'B']
assert floor == '2.3'
# -- backup state on the reports table ---------------------------------------
def test_backup_state_reports_the_stalest_measurable_kind(db):
"""A host is judged by the WORST backup it has, not the best.
This used to prefer the most recently confirmed revision. That was right
while a PC reported one kind, and became wrong when part markers arrived: a
marker bay reports ntlars AND partmarker, so a daily ntlars post kept the
badge green while the marker backup had been failing for a month. A host is
healthy only when everything it backs up is current, and the badge names the
kind that is not.
A revision that has never been confirmed (lastseenat NULL) still never wins.
It is not stale, it is unmeasurable - and letting it win hid a working
backup entirely, because the verdict for a NULL confirmation is tri-state
None, which renders as no badge at all.
Ordering happens in Python on purpose. Doing it in SQL needs
ORDER BY ... NULLS LAST, which SQLite accepts and MySQL rejects outright -
so it passed every test here and returned a 500 against the real database.
"""
from datetime import timedelta
from plugins.backups.models import BackupRevision
from plugins.geenforce.api.routes import _attach_backup_state
from shopdb.api import Asset, AssetType
assettype = AssetType.query.first()
if not assettype:
assettype = AssetType(assettype='computer')
db.session.add(assettype)
db.session.flush()
asset = Asset(assetnumber='PC-1', name='PC-1',
assettypeid=assettype.assettypeid)
db.session.add(asset)
db.session.flush()
now = datetime.now(timezone.utc).replace(tzinfo=None)
for kind, lastseen in (('ntlars', now - timedelta(days=9)),
('udc', now),
('files', None)):
db.session.add(BackupRevision(
assetid=asset.assetid, backupkind=kind, contenthash=kind * 8,
sourcehostname='WJPC01', lastseenat=lastseen, createdat=now))
db.session.flush()
facts = {'wjpc01': {'backupkind': None, 'backuplastseen': None}}
_attach_backup_state(facts, {'WJPC01'})
# ntlars is 9 days old, udc was confirmed just now: the host is judged by
# ntlars, and the badge names it so somebody knows WHICH backup stopped.
assert facts['wjpc01']['backupkind'] == 'ntlars'
# 'files' has never been confirmed, so it neither wins nor blanks the badge.
assert facts['wjpc01']['backuplastseen'] is not None
# -- reported display subtype -------------------------------------------------
def test_report_stores_the_subtype_the_device_reports(db):
"""A kiosk knows whether it is a Dashboard or a Lobby; it now says so.
Previously the fleet table inferred this from the DashboardDefault fqdn
mapping, which is empty unless somebody added a row per kiosk, so the
column was blank for every display.
"""
from plugins.geenforce.service import record_enforcement_report
record_enforcement_report({'hostname': 'WJKIOSK01', 'scopename': 'gea-shopfloor-display',
'subtype': 'Lobby', 'counts': {}})
db.session.flush()
stored = ManifestEnforcementReport.query.filter_by(
hostname='WJKIOSK01', iscurrent=True).one()
assert stored.subtype == 'Lobby'
def test_report_without_a_subtype_stores_none(db):
"""Every non-display PC type has no display-type.txt, so it reports no
subtype - that must land as NULL, not as an empty string."""
from plugins.geenforce.service import record_enforcement_report
record_enforcement_report({'hostname': 'WJPC77', 'scopename': 'common',
'subtype': '', 'counts': {}})
db.session.flush()
stored = ManifestEnforcementReport.query.filter_by(
hostname='WJPC77', iscurrent=True).one()
assert stored.subtype is None
def test_reported_subtype_is_normalised_to_the_role_vocabulary(db):
"""display-type.txt is hand-edited, so its casing drifts across the fleet.
Normalising on the way in keeps the fleet table reading one vocabulary
instead of whatever each kiosk happened to be typed as.
"""
from plugins.geenforce.service import record_enforcement_report
for index, reported in enumerate(('lobby', 'LOBBY', ' Lobby ')):
host = f'WJKIOSKCASE{index}'
record_enforcement_report({'hostname': host,
'scopename': 'gea-shopfloor-display',
'subtype': reported, 'counts': {}})
db.session.flush()
stored = ManifestEnforcementReport.query.filter_by(
hostname=host, iscurrent=True).one()
assert stored.subtype == 'Lobby', reported
def test_reported_subtype_keeps_an_unknown_value_verbatim(db):
"""An unrecognised subtype is a typo on a kiosk or a role nobody declared.
Both are worth SEEING in the fleet table, so the value is stored as sent
rather than blanked or guessed at.
"""
from plugins.geenforce.service import record_enforcement_report
record_enforcement_report({'hostname': 'WJKIOSKODD', 'scopename': 'gea-shopfloor-display',
'subtype': 'Lobbby', 'counts': {}})
db.session.flush()
stored = ManifestEnforcementReport.query.filter_by(
hostname='WJKIOSKODD', iscurrent=True).one()
assert stored.subtype == 'Lobbby'
# -- what the gate compares against ------------------------------------------
#
# The gate used manifestversion as the library floor. That is the manifest's own
# 'Version' field: for a share-imported manifest it happens to share the
# library's numbering, but a code-authored scope sets it freely -
# seed_display_scope writes '2.0' - so the fleet was compared against a number
# that says nothing about library features, and the gate passed on the very
# scope that most needed it.
def test_a_declared_minlibversion_is_what_the_fleet_is_judged_against(db):
scope = _scope(manifestversion='2.0')
scope.minlibversion = '2.6'
db.session.flush()
assert service.required_libversion(scope) == '2.6'
_report('KIOSK01', '2.4')
with pytest.raises(service.LibVersionTooOldError):
service.publish_scope('gea-shopfloor-cmm', 'runtime')
def test_the_floor_is_derived_from_the_features_the_manifest_uses(db):
"""A scope written before minlibversion existed is still gated on what it
contains: a version-gated entry needs lib 2.6, whatever its own Version
field claims."""
from plugins.geenforce.models import ManifestEntry
scope = _scope(manifestversion='2.0')
scope.entries.append(ManifestEntry(
name='PC-DMIS 2023.2', entrytype='MSI', sortorder=0,
cmmversion='2023'))
db.session.flush()
assert service.required_libversion(scope) == '2.6'
def test_a_scope_using_nothing_special_falls_back_to_its_own_version(db):
scope = _scope(manifestversion='2.4')
assert service.required_libversion(scope) == '2.4'

View File

@@ -1,137 +0,0 @@
"""GE-Enforce enforcement-report per-entry key contract.
The server reads LOWERCASE keys from each results[] item -
name / action / selfhealed / exitcode / message (see
service.record_enforcement_report) - and maps them onto the
ManifestEnforcementResult columns entryname / action / selfhealed / exitcode /
message. This is the exact contract the PowerShell client fix targets: the
client emits lowercase keys, so if the server ever silently switched to reading
uppercase keys the client's entryname/selfhealed would drop to empty/false.
These tests POST a realistic multi-entry cycle with the lowercase keys and lock
that the result columns populate, plus the negative case (uppercase keys do NOT
populate) so a future contract flip fails loudly here.
"""
from plugins.geenforce import service
SCOPE = {
'Version': '2.6',
'Applications': [
{'Name': 'VNC firewall rule', 'Type': 'PS1', 'Script': 'scripts/vnc.ps1'},
{'Name': 'PC-DMIS 2023.1', 'Type': 'MSI', 'Installer': 'apps/pcdmis.msi'},
{'Name': 'eDNC config', 'Type': 'PS1', 'Script': 'scripts/ednc.ps1'},
{'Name': 'Blancco agent', 'Type': 'MSI', 'Installer': 'apps/blancco.msi'},
],
}
def _seed_and_publish(app, scopename='gea-shopfloor-cmm'):
with app.app_context():
service.replace_scope_draft(scopename, 'runtime', SCOPE)
service.publish_scope(scopename, 'runtime', notes='v')
service.db.session.commit()
def _report_token(client, auth_headers):
resp = client.post('/api/apitokens',
json={'name': 'svc', 'scopes': ['geenforce.report']},
headers=auth_headers)
assert resp.status_code == 201, resp.get_json()
return resp.get_json()['data']['secret']
def test_lowercase_keys_populate_result_columns(client, db, app, auth_headers):
_seed_and_publish(app)
secret = _report_token(client, auth_headers)
post = client.post('/api/geenforce/report', json={
'hostname': 'WJDISPLAY07',
'scopename': 'gea-shopfloor-cmm',
'phase': 'runtime',
'appliedversion': 1,
'enforcerversion': '2.6',
'counts': {'installed': 2, 'skipped': 1, 'failed': 1, 'filtered': 0},
'results': [
{'name': 'VNC firewall rule', 'action': 'installed',
'selfhealed': True, 'exitcode': 0, 'message': 'rule re-added'},
{'name': 'PC-DMIS 2023.1', 'action': 'skipped',
'selfhealed': False, 'exitcode': 0, 'message': ''},
{'name': 'eDNC config', 'action': 'installed',
'selfhealed': True, 'exitcode': 0},
{'name': 'Blancco agent', 'action': 'failed',
'selfhealed': False, 'exitcode': 1603, 'message': 'MSI 1603'},
],
}, headers={'X-API-Key': secret})
assert post.status_code == 200, post.get_json()
reportid = post.get_json()['data']['reportid']
detail = client.get(f'/api/geenforce/reports/{reportid}',
headers=auth_headers).get_json()['data']
byname = {r['entryname']: r for r in detail['results']}
# Every lowercase 'name' mapped onto entryname (nothing dropped).
assert set(byname) == {'VNC firewall rule', 'PC-DMIS 2023.1',
'eDNC config', 'Blancco agent'}
vnc = byname['VNC firewall rule']
assert vnc['action'] == 'installed'
assert vnc['selfhealed'] is True
assert vnc['exitcode'] == 0
assert vnc['message'] == 'rule re-added'
assert byname['PC-DMIS 2023.1']['action'] == 'skipped'
assert byname['PC-DMIS 2023.1']['selfhealed'] is False
ednc = byname['eDNC config']
assert ednc['selfhealed'] is True
assert ednc['message'] is None # omitted key -> None
blancco = byname['Blancco agent']
assert blancco['action'] == 'failed'
assert blancco['exitcode'] == 1603
assert blancco['message'] == 'MSI 1603'
# A failure is present, so the cycle status is 'failed' (a self-heal without
# any failure would be 'selfhealed').
assert detail['status'] == 'failed'
def test_selfhealed_defaults_false_when_key_omitted(client, db, app,
auth_headers):
_seed_and_publish(app)
secret = _report_token(client, auth_headers)
post = client.post('/api/geenforce/report', json={
'hostname': 'WJDISPLAY08',
'scopename': 'gea-shopfloor-cmm',
'results': [{'name': 'asset report', 'action': 'installed'}],
}, headers={'X-API-Key': secret})
reportid = post.get_json()['data']['reportid']
detail = client.get(f'/api/geenforce/reports/{reportid}',
headers=auth_headers).get_json()['data']
result = detail['results'][0]
assert result['entryname'] == 'asset report'
assert result['selfhealed'] is False
def test_uppercase_keys_do_not_populate(client, db, app, auth_headers):
"""Negative lock: the server reads LOWERCASE keys, so an uppercase-keyed
item does NOT populate entryname/selfhealed. Guards against a silent flip to
uppercase that would break the client's lowercase payload."""
_seed_and_publish(app)
secret = _report_token(client, auth_headers)
post = client.post('/api/geenforce/report', json={
'hostname': 'WJDISPLAY09',
'scopename': 'gea-shopfloor-cmm',
'results': [{'Name': 'wrong casing', 'Action': 'installed',
'SelfHealed': True}],
}, headers={'X-API-Key': secret})
reportid = post.get_json()['data']['reportid']
detail = client.get(f'/api/geenforce/reports/{reportid}',
headers=auth_headers).get_json()['data']
result = detail['results'][0]
assert result['entryname'] == '' # 'Name' not read -> default ''
assert result['selfhealed'] is False # 'SelfHealed' not read -> default

View File

@@ -1,565 +0,0 @@
"""GE-Enforce observed-state reporting: PCs POST enforcement results.
Covers the report ingest (applied version + per-entry self-heal outcomes), the
latest-per-host upsert, received-latest derivation (did the PC pick up the newest
published manifest?), status derivation (ok / selfhealed / failed), and the admin
fleet-compliance views.
"""
import json
from plugins.geenforce import service
SCOPE = {
'Version': '2.6',
'Applications': [
{'Name': 'Alpha', 'Type': 'MSI', 'Installer': 'apps/alpha.msi'},
{'Name': 'Beta', 'Type': 'PS1', 'Script': 'scripts/beta.ps1'},
],
}
def _seed_and_publish(app, scopename='gea-shopfloor-cmm'):
with app.app_context():
service.replace_scope_draft(scopename, 'runtime', SCOPE)
version = service.publish_scope(scopename, 'runtime', notes='v')
service.db.session.commit()
return version
def _token(client, auth_headers, scopes):
resp = client.post('/api/apitokens',
json={'name': 'svc', 'scopes': scopes},
headers=auth_headers)
assert resp.status_code == 201, resp.get_json()
return resp.get_json()['data']['secret']
def test_report_malformed_counts_is_400(client, db, app, auth_headers):
"""A wrong JSON shape (counts as a string, results as a string) must be a
clean 400, not a 500 from a later .get()/iteration."""
_seed_and_publish(app)
secret = _token(client, auth_headers, ['geenforce.report'])
for bad in ({'counts': 'nope'}, {'results': 'nope'}):
body = {'hostname': 'WJCMM01', 'scopename': 'gea-shopfloor-cmm'}
body.update(bad)
resp = client.post('/api/geenforce/report', json=body,
headers={'X-API-Key': secret})
assert resp.status_code == 400, resp.get_json()
def test_report_recorded_and_listed(client, db, app, auth_headers):
_seed_and_publish(app)
secret = _token(client, auth_headers, ['geenforce.report'])
resp = client.post('/api/geenforce/report', json={
'hostname': 'WJCMM01', 'scopename': 'gea-shopfloor-cmm',
'appliedversion': 1, 'enforcerversion': '2.6',
'counts': {'installed': 1, 'skipped': 1, 'failed': 0, 'filtered': 0},
'results': [
{'name': 'Alpha', 'action': 'installed', 'selfhealed': True},
{'name': 'Beta', 'action': 'skipped'},
],
}, headers={'X-API-Key': secret})
assert resp.status_code == 200, resp.get_json()
assert resp.get_json()['data']['status'] == 'selfhealed'
listing = client.get('/api/geenforce/reports', headers=auth_headers)
rows = listing.get_json()['data']
assert len(rows) == 1
row = rows[0]
assert row['hostname'] == 'WJCMM01'
assert row['appliedversion'] == 1
assert row['latestversion'] == 1
assert row['receivedlatest'] is True
assert row['installed'] == 1 and row['skipped'] == 1
def test_received_latest_flips_when_new_version_published(client, db, app,
auth_headers):
_seed_and_publish(app)
secret = _token(client, auth_headers, ['geenforce.report'])
# enforcerversion matters here: the publish gate refuses to ship a 2.6
# manifest to a PC that cannot prove it runs at least lib 2.6, and a report
# with no version is treated as an old lib.
client.post('/api/geenforce/report',
json={'hostname': 'WJCMM02', 'scopename': 'gea-shopfloor-cmm',
'appliedversion': 1, 'counts': {},
'enforcerversion': '2.6'},
headers={'X-API-Key': secret})
# Publish a newer version; the PC is now behind.
with app.app_context():
service.publish_scope('gea-shopfloor-cmm', 'runtime', notes='v2')
service.db.session.commit()
row = client.get('/api/geenforce/reports?hostname=WJCMM02',
headers=auth_headers).get_json()['data'][0]
assert row['appliedversion'] == 1
assert row['latestversion'] == 2
assert row['receivedlatest'] is False
def test_latest_report_upserts_per_host(client, db, app, auth_headers):
_seed_and_publish(app)
secret = _token(client, auth_headers, ['geenforce.report'])
for installed in (0, 1):
client.post('/api/geenforce/report',
json={'hostname': 'WJCMM03', 'scopename': 'gea-shopfloor-cmm',
'appliedversion': 1,
'counts': {'installed': installed}},
headers={'X-API-Key': secret})
rows = client.get('/api/geenforce/reports?hostname=WJCMM03',
headers=auth_headers).get_json()['data']
assert len(rows) == 1 # only the latest is current
assert rows[0]['installed'] == 1
def test_hostname_case_variation_keeps_single_current(client, db, app, auth_headers):
"""A PC reporting its hostname in different casing must not leave two
iscurrent rows (the demote is case-insensitive, matching the ilike read)."""
_seed_and_publish(app)
secret = _token(client, auth_headers, ['geenforce.report'])
for host in ('WJCMM09', 'wjcmm09'):
client.post('/api/geenforce/report',
json={'hostname': host, 'scopename': 'gea-shopfloor-cmm',
'appliedversion': 1, 'counts': {'installed': 1}},
headers={'X-API-Key': secret})
from plugins.geenforce.models import ManifestEnforcementReport
with app.app_context():
current = ManifestEnforcementReport.query.filter(
ManifestEnforcementReport.hostname.ilike('WJCMM09'),
ManifestEnforcementReport.iscurrent == True).all()
assert len(current) == 1
def test_status_failed_when_failures(client, db, app, auth_headers):
_seed_and_publish(app)
secret = _token(client, auth_headers, ['geenforce.report'])
resp = client.post('/api/geenforce/report',
json={'hostname': 'WJCMM04', 'scopename': 'gea-shopfloor-cmm',
'counts': {'failed': 1},
'results': [{'name': 'Alpha', 'action': 'failed',
'exitcode': 1603,
'message': 'MSI 1603'}]},
headers={'X-API-Key': secret})
assert resp.get_json()['data']['status'] == 'failed'
def test_report_detail_shows_per_entry_results(client, db, app, auth_headers):
_seed_and_publish(app)
secret = _token(client, auth_headers, ['geenforce.report'])
post = client.post('/api/geenforce/report',
json={'hostname': 'WJCMM05', 'scopename': 'gea-shopfloor-cmm',
'results': [
{'name': 'Alpha', 'action': 'installed',
'selfhealed': True},
{'name': 'Beta', 'action': 'failed',
'exitcode': 1, 'message': 'boom'}]},
headers={'X-API-Key': secret})
reportid = post.get_json()['data']['reportid']
detail = client.get(f'/api/geenforce/reports/{reportid}',
headers=auth_headers).get_json()['data']
results = {r['entryname']: r for r in detail['results']}
assert results['Alpha']['selfhealed'] is True
assert results['Beta']['action'] == 'failed'
assert results['Beta']['message'] == 'boom'
def test_report_requires_report_scope(client, db, app, auth_headers):
_seed_and_publish(app)
# A fetch-only token cannot report.
secret = _token(client, auth_headers, ['geenforce.fetch'])
resp = client.post('/api/geenforce/report',
json={'hostname': 'WJCMM06'},
headers={'X-API-Key': secret})
assert resp.status_code == 401
def test_report_unauthenticated_rejected(client, db, app):
resp = client.post('/api/geenforce/report', json={'hostname': 'WJCMM07'})
assert resp.status_code == 401
def test_report_missing_hostname_rejected(client, db, app, auth_headers):
secret = _token(client, auth_headers, ['geenforce.report'])
resp = client.post('/api/geenforce/report', json={'scopename': 'x'},
headers={'X-API-Key': secret})
assert resp.status_code == 400
# -- going quiet ------------------------------------------------------------
#
# A report records how one cycle went; nothing ages it. A PC switched off after
# a clean run therefore kept 'ok' indefinitely and read as healthy - which is
# how a machine offline for over a day still showed 'ok'.
def _age_report(db, hostname, minutes):
"""Push a stored report's server-side receivedat back in time."""
from datetime import timedelta
from plugins.geenforce.models import ManifestEnforcementReport
from plugins.geenforce.service import _utcnow
(ManifestEnforcementReport.query
.filter_by(hostname=hostname, iscurrent=True)
.update({'receivedat': _utcnow() - timedelta(minutes=minutes)}))
db.session.commit()
def _post_clean_report(client, secret, hostname):
return client.post('/api/geenforce/report', json={
'hostname': hostname, 'scopename': 'gea-shopfloor-cmm',
'appliedversion': 1, 'enforcerversion': '2.6',
'counts': {'installed': 1, 'skipped': 0, 'failed': 0, 'filtered': 0},
'results': [{'name': 'Alpha', 'action': 'installed'}],
}, headers={'X-API-Key': secret})
def test_a_pc_reporting_now_is_not_stale(client, db, app, auth_headers):
_seed_and_publish(app)
secret = _token(client, auth_headers, ['geenforce.report'])
_post_clean_report(client, secret, 'WJCMM01')
row = client.get('/api/geenforce/reports',
headers=auth_headers).get_json()['data'][0]
assert row['status'] == 'ok'
assert row['isstale'] is False
assert row['staleafterminutes'] == 30
def test_a_pc_that_stopped_reporting_goes_stale(client, db, app, auth_headers):
_seed_and_publish(app)
secret = _token(client, auth_headers, ['geenforce.report'])
_post_clean_report(client, secret, 'WJCMM01')
_age_report(db, 'WJCMM01', minutes=60 * 26) # offline over a day
row = client.get('/api/geenforce/reports',
headers=auth_headers).get_json()['data'][0]
assert row['isstale'] is True
# The REPORTED status is left alone - it is still a true record of the last
# cycle, and the UI shows it in the tooltip behind the stale badge.
assert row['status'] == 'ok'
def test_stale_boundary_is_the_configured_threshold(client, db, app, auth_headers):
_seed_and_publish(app)
secret = _token(client, auth_headers, ['geenforce.report'])
_post_clean_report(client, secret, 'WJCMM01')
_age_report(db, 'WJCMM01', minutes=29)
row = client.get('/api/geenforce/reports',
headers=auth_headers).get_json()['data'][0]
assert row['isstale'] is False, 'inside the window is not stale'
_age_report(db, 'WJCMM01', minutes=31)
row = client.get('/api/geenforce/reports',
headers=auth_headers).get_json()['data'][0]
assert row['isstale'] is True, 'past the window is stale'
def test_stale_threshold_is_a_setting(client, db, app, auth_headers):
from shopdb.api import Setting
_seed_and_publish(app)
secret = _token(client, auth_headers, ['geenforce.report'])
_post_clean_report(client, secret, 'WJCMM01')
_age_report(db, 'WJCMM01', minutes=45)
Setting.set('geenforce_reportstaleminutes', '120', valuetype='integer',
category='geenforce')
db.session.commit()
row = client.get('/api/geenforce/reports',
headers=auth_headers).get_json()['data'][0]
assert row['isstale'] is False
assert row['staleafterminutes'] == 120
def test_zero_disables_the_stale_check(client, db, app, auth_headers):
from shopdb.api import Setting
_seed_and_publish(app)
secret = _token(client, auth_headers, ['geenforce.report'])
_post_clean_report(client, secret, 'WJCMM01')
_age_report(db, 'WJCMM01', minutes=60 * 24 * 7)
Setting.set('geenforce_reportstaleminutes', '0', valuetype='integer',
category='geenforce')
db.session.commit()
row = client.get('/api/geenforce/reports',
headers=auth_headers).get_json()['data'][0]
assert row['isstale'] is False
assert row['staleafterminutes'] == 0
def test_a_pc_still_posting_with_a_stalled_checkin_is_stale(client, db, app,
auth_headers):
"""The other way round: the server hears from it, but its own cycle stopped.
receivedat stays current while the PC's self-reported lastcheckin does not
advance. The fleet table has a column for each, and either going quiet is a
problem worth seeing.
"""
from datetime import timedelta
from plugins.geenforce.service import _utcnow
_seed_and_publish(app)
secret = _token(client, auth_headers, ['geenforce.report'])
client.post('/api/geenforce/report', json={
'hostname': 'WJCMM01', 'scopename': 'gea-shopfloor-cmm',
'appliedversion': 1, 'enforcerversion': '2.6',
'lastcheckin': (_utcnow() - timedelta(hours=22)).isoformat() + 'Z',
'counts': {'installed': 1, 'skipped': 0, 'failed': 0, 'filtered': 0},
'results': [{'name': 'Alpha', 'action': 'installed'}],
}, headers={'X-API-Key': secret})
row = client.get('/api/geenforce/reports',
headers=auth_headers).get_json()['data'][0]
assert row['status'] == 'ok', 'the cycle itself reported clean'
assert row['isstale'] is True, 'but it has not checked in for 22 hours'
def test_a_future_checkin_cannot_mask_server_side_silence(client, db, app,
auth_headers):
"""lastcheckin is client-asserted, so it must not be able to vouch for a PC.
Taking the newer of the two timestamps would let a PC with a wrong clock
report itself healthy forever. receivedat is checked on its own.
"""
from datetime import timedelta
from plugins.geenforce.service import _utcnow
_seed_and_publish(app)
secret = _token(client, auth_headers, ['geenforce.report'])
client.post('/api/geenforce/report', json={
'hostname': 'WJCMM01', 'scopename': 'gea-shopfloor-cmm',
'appliedversion': 1, 'enforcerversion': '2.6',
'lastcheckin': (_utcnow() + timedelta(days=3)).isoformat() + 'Z',
'counts': {'installed': 1, 'skipped': 0, 'failed': 0, 'filtered': 0},
'results': [{'name': 'Alpha', 'action': 'installed'}],
}, headers={'X-API-Key': secret})
_age_report(db, 'WJCMM01', minutes=60 * 26)
row = client.get('/api/geenforce/reports',
headers=auth_headers).get_json()['data'][0]
assert row['isstale'] is True
def test_asset_link_uses_the_plugin_id_not_the_assetid(client, db, app, auth_headers):
"""/machines/:id keys on machineid, NOT the core assetid.
Returning the assetid made the fleet table link to whichever machine
happened to carry that number - a wrong page that looks right rather than a
404. The frontend links on machinepluginid for exactly this reason.
"""
from shopdb.core.models import Asset, AssetType
from shopdb.core.models.relationship import RelationshipType, AssetRelationship
from plugins.computers.models import Computer
from plugins.machines.models import Machine
_seed_and_publish(app)
secret = _token(client, auth_headers, ['geenforce.report'])
computertype = AssetType.query.filter_by(assettype='computer').first() \
or AssetType(assettype='computer')
machinetype = AssetType.query.filter_by(assettype='machine').first() \
or AssetType(assettype='machine')
db.session.add_all([computertype, machinetype])
db.session.flush()
pcasset = Asset(assetnumber='WJBAY01', assettypeid=computertype.assettypeid)
machineasset = Asset(assetnumber='6905', assettypeid=machinetype.assettypeid)
db.session.add_all([pcasset, machineasset])
db.session.flush()
db.session.add(Computer(assetid=pcasset.assetid, hostname='WJBAY01'))
machine = Machine(assetid=machineasset.assetid)
db.session.add(machine)
db.session.flush()
controls = RelationshipType.query.filter_by(relationshiptype='controls').first()
if not controls:
controls = RelationshipType(relationshiptype='controls')
db.session.add(controls)
db.session.flush()
db.session.add(AssetRelationship(
sourceassetid=pcasset.assetid, targetassetid=machineasset.assetid,
relationshiptypeid=controls.relationshiptypeid, label='collector:machine'))
db.session.commit()
client.post('/api/geenforce/report', json={
'hostname': 'WJBAY01', 'scopename': 'gea-shopfloor-cmm', 'counts': {},
}, headers={'X-API-Key': secret})
row = [r for r in client.get('/api/geenforce/reports',
headers=auth_headers).get_json()['data']
if r['hostname'] == 'WJBAY01'][0]
assert row['machinenumber'] == '6905'
assert row['machineassetid'] == machineasset.assetid
assert row['machinepluginid'] == machine.machineid
# -- backup verdict ----------------------------------------------------------
#
# The column used to show a raw date, which read as neglect: dedup means an
# unchanged config writes no new revision, so a day-old confirmation IS the
# healthy steady state at the default 24h collection interval.
def _backup_revision(db, hostname, kind, ageda):
from datetime import timedelta
from plugins.backups.models import BackupRevision
from plugins.geenforce.service import _utcnow
from shopdb.core.models import Asset, AssetType
from plugins.computers.models import Computer
computertype = AssetType.query.filter_by(assettype='computer').first() \
or AssetType(assettype='computer')
db.session.add(computertype)
db.session.flush()
asset = Asset(assetnumber=hostname, assettypeid=computertype.assettypeid)
db.session.add(asset)
db.session.flush()
db.session.add(Computer(assetid=asset.assetid, hostname=hostname))
db.session.add(BackupRevision(
assetid=asset.assetid, backupkind=kind, sourcehostname=hostname,
contenthash='0' * 64,
# captured well before the last confirmation on purpose: an unchanged
# config is the normal case, and the two dates must not be conflated
collectedat=_utcnow() - timedelta(days=ageda + 30),
lastseenat=_utcnow() - timedelta(days=ageda)))
db.session.commit()
def _row_for(client, auth_headers, hostname):
return [r for r in client.get('/api/geenforce/reports',
headers=auth_headers).get_json()['data']
if r['hostname'] == hostname][0]
def test_a_recent_check_is_good_even_though_nothing_changed(client, db, app, auth_headers):
_seed_and_publish(app)
secret = _token(client, auth_headers, ['geenforce.report'])
_backup_revision(db, 'WJBAK01', 'ntlars', ageda=1)
client.post('/api/geenforce/report', json={
'hostname': 'WJBAK01', 'scopename': 'gea-shopfloor-cmm', 'counts': {}},
headers={'X-API-Key': secret})
row = _row_for(client, auth_headers, 'WJBAK01')
assert row['backupkind'] == 'ntlars'
assert row['backupok'] is True, 'a day old is the healthy steady state'
assert row['backupstaleafterdays'] == 3
def test_a_check_older_than_the_threshold_is_stale(client, db, app, auth_headers):
_seed_and_publish(app)
secret = _token(client, auth_headers, ['geenforce.report'])
_backup_revision(db, 'WJBAK02', 'ntlars', ageda=9)
client.post('/api/geenforce/report', json={
'hostname': 'WJBAK02', 'scopename': 'gea-shopfloor-cmm', 'counts': {}},
headers={'X-API-Key': secret})
assert _row_for(client, auth_headers, 'WJBAK02')['backupok'] is False
def test_no_backup_at_all_is_unknown_not_good(client, db, app, auth_headers):
"""None, never True: 'never seen' must not render as a green badge."""
_seed_and_publish(app)
secret = _token(client, auth_headers, ['geenforce.report'])
client.post('/api/geenforce/report', json={
'hostname': 'WJBAK03', 'scopename': 'gea-shopfloor-cmm', 'counts': {}},
headers={'X-API-Key': secret})
row = _row_for(client, auth_headers, 'WJBAK03')
assert row['backupkind'] is None
assert row['backupok'] is None
def test_the_capture_date_is_reported_separately_from_the_check(client, db, app, auth_headers):
"""Two dates, two meanings: when it was last CHECKED vs last CAPTURED.
collectedat only moves when the config actually changed, so a healthy
machine has a recent check against an old capture. The hover says both;
one date would have to stand for both and ends up meaning neither.
"""
_seed_and_publish(app)
secret = _token(client, auth_headers, ['geenforce.report'])
_backup_revision(db, 'WJBAK04', 'ntlars', ageda=1)
client.post('/api/geenforce/report', json={
'hostname': 'WJBAK04', 'scopename': 'gea-shopfloor-cmm', 'counts': {}},
headers={'X-API-Key': secret})
row = _row_for(client, auth_headers, 'WJBAK04')
assert row['backupok'] is True
assert row['backuplastseen'] is not None
assert row['backupcollectedat'] is not None
assert row['backupcollectedat'] < row['backuplastseen'], \
'captured before it was last confirmed'
def _extra_revision(db, hostname, kind, ageda, contenthash=None):
"""A second backup kind for a host that already exists.
_backup_revision creates the asset and the Computer row, so it cannot be
called twice for one host.
"""
from datetime import timedelta
from plugins.backups.models import BackupRevision
from plugins.geenforce.service import _utcnow
from plugins.computers.models import Computer
computer = Computer.query.filter_by(hostname=hostname).first()
db.session.add(BackupRevision(
assetid=computer.assetid, backupkind=kind, sourcehostname=hostname,
contenthash=(contenthash or (kind[0] * 64))[:64],
collectedat=_utcnow() - timedelta(days=ageda + 30),
lastseenat=_utcnow() - timedelta(days=ageda)))
db.session.commit()
def test_a_stale_kind_is_not_hidden_by_a_healthy_one(client, db, app, auth_headers):
"""A marker bay reports TWO kinds, and the badge must not report the better.
ntlars posts daily for the machine's DNC settings; partmarker posts the
controller's patterns. Judging a host by its newest revision meant a
marker backup that had been failing for a week still showed green, because
ntlars kept the badge fed. The failure was then visible only on the
marker's own asset page, which is not where anyone looks to discover that
something stopped.
"""
_seed_and_publish(app)
secret = _token(client, auth_headers, ['geenforce.report'])
_backup_revision(db, 'WJBAK10', 'ntlars', ageda=1)
_extra_revision(db, 'WJBAK10', 'partmarker', ageda=9)
client.post('/api/geenforce/report', json={
'hostname': 'WJBAK10', 'scopename': 'gea-shopfloor-cmm', 'counts': {}},
headers={'X-API-Key': secret})
row = _row_for(client, auth_headers, 'WJBAK10')
assert row['backupok'] is False
# And it names the kind that is actually broken, not the healthy one.
assert row['backupkind'] == 'partmarker'
def test_a_host_is_good_only_when_every_kind_is_current(client, db, app, auth_headers):
_seed_and_publish(app)
secret = _token(client, auth_headers, ['geenforce.report'])
_backup_revision(db, 'WJBAK11', 'ntlars', ageda=1)
_extra_revision(db, 'WJBAK11', 'partmarker', ageda=1)
client.post('/api/geenforce/report', json={
'hostname': 'WJBAK11', 'scopename': 'gea-shopfloor-cmm', 'counts': {}},
headers={'X-API-Key': secret})
row = _row_for(client, auth_headers, 'WJBAK11')
assert row['backupok'] is True
def test_an_older_revision_of_a_kind_does_not_make_it_look_stale(client, db, app, auth_headers):
"""lastseenat only ever moves on the NEWEST revision of a chain.
Older revisions keep the value they had when they were superseded, so
judging on every row rather than the freshest per kind would report a
healthy, frequently changing config as permanently stale.
"""
_seed_and_publish(app)
secret = _token(client, auth_headers, ['geenforce.report'])
_backup_revision(db, 'WJBAK12', 'ntlars', ageda=1)
_extra_revision(db, 'WJBAK12', 'ntlars', ageda=40, contenthash='f' * 64)
client.post('/api/geenforce/report', json={
'hostname': 'WJBAK12', 'scopename': 'gea-shopfloor-cmm', 'counts': {}},
headers={'X-API-Key': secret})
row = _row_for(client, auth_headers, 'WJBAK12')
assert row['backupok'] is True
assert row['backupkind'] == 'ntlars'

View File

@@ -1,128 +0,0 @@
"""Resource-scope binding on geenforce fetch tokens.
A geenforce.fetch token MAY be pinned to specific manifest scopes
(ApiToken.resourcescopes). A bound token can only pull those scopes' manifests
and only the blobs those scopes reference; an unbound token (NULL) keeps the
original behavior. This is what lets a fleet-wide display key be handed out
without exposing every scope + every blob in the content store.
"""
import hashlib
from plugins.geenforce import service
DISPLAY = 'gea-shopfloor-display'
OTHER = 'gea-shopfloor-cmm'
KIOSK_BYTES = b'kiosk-installer-payload-bytes'
KIOSK_SHA = hashlib.sha256(KIOSK_BYTES).hexdigest()
def _publish_display_scope(app):
"""Publish a display scope whose one entry references KIOSK_SHA over http."""
with app.app_context():
service.store_blob(KIOSK_BYTES, 'kiosk.exe', 'application/octet-stream')
manifest = {
'Version': '2.0',
'Applications': [
{'Name': 'Kiosk', 'Type': 'EXE', 'Installer': 'apps/kiosk.exe',
'DetectionMethod': 'Always',
'PayloadSource': 'http', 'PayloadSha256': KIOSK_SHA},
],
}
service.replace_scope_draft(DISPLAY, 'runtime', manifest)
service.publish_scope(DISPLAY, 'runtime', notes='display')
# A second, unrelated scope + blob the display token must NOT reach.
service.store_blob(b'secret-other-scope-bytes', 'other.exe')
service.replace_scope_draft(OTHER, 'runtime', {
'Version': '2.6',
'Applications': [{'Name': 'Beta', 'Type': 'PS1',
'Script': 'scripts/beta.ps1',
'DetectionMethod': 'Always'}]})
service.publish_scope(OTHER, 'runtime', notes='other')
service.db.session.commit()
def _mint(client, auth_headers, resourcescopes=None):
payload = {'name': 'display svc', 'scopes': ['geenforce.fetch']}
if resourcescopes is not None:
payload['resourcescopes'] = resourcescopes
resp = client.post('/api/apitokens', json=payload, headers=auth_headers)
assert resp.status_code == 201, resp.get_json()
return resp.get_json()['data']
def _key(client, auth_headers, resourcescopes=None):
return {'X-API-Key': _mint(client, auth_headers, resourcescopes)['secret']}
# -- manifest binding ---------------------------------------------------------
def test_bound_token_gets_its_own_scope(client, app, db, auth_headers):
_publish_display_scope(app)
headers = _key(client, auth_headers, resourcescopes=[DISPLAY])
resp = client.get(f'/api/geenforce/manifest?pctype={DISPLAY}', headers=headers)
assert resp.status_code == 200
def test_bound_token_forbidden_other_scope(client, app, db, auth_headers):
_publish_display_scope(app)
headers = _key(client, auth_headers, resourcescopes=[DISPLAY])
resp = client.get(f'/api/geenforce/manifest?pctype={OTHER}', headers=headers)
assert resp.status_code == 403
def test_unbound_token_reaches_any_scope(client, app, db, auth_headers):
_publish_display_scope(app)
headers = _key(client, auth_headers) # no resourcescopes -> unrestricted
assert client.get(f'/api/geenforce/manifest?pctype={DISPLAY}',
headers=headers).status_code == 200
assert client.get(f'/api/geenforce/manifest?pctype={OTHER}',
headers=headers).status_code == 200
# -- payload binding ----------------------------------------------------------
def test_bound_token_gets_referenced_blob(client, app, db, auth_headers):
_publish_display_scope(app)
headers = _key(client, auth_headers, resourcescopes=[DISPLAY])
resp = client.get(f'/api/geenforce/payload/{KIOSK_SHA}', headers=headers)
assert resp.status_code == 200
assert resp.data == KIOSK_BYTES
def test_bound_token_404_on_unreferenced_blob(client, app, db, auth_headers):
_publish_display_scope(app)
# A blob the display scope does NOT reference (belongs to OTHER).
other_sha = hashlib.sha256(b'secret-other-scope-bytes').hexdigest()
headers = _key(client, auth_headers, resourcescopes=[DISPLAY])
resp = client.get(f'/api/geenforce/payload/{other_sha}', headers=headers)
assert resp.status_code == 404
def test_unbound_token_gets_any_blob(client, app, db, auth_headers):
_publish_display_scope(app)
other_sha = hashlib.sha256(b'secret-other-scope-bytes').hexdigest()
headers = _key(client, auth_headers)
assert client.get(f'/api/geenforce/payload/{other_sha}',
headers=headers).status_code == 200
# -- token model / API --------------------------------------------------------
def test_create_persists_resourcescopes(client, app, db, auth_headers):
data = _mint(client, auth_headers, resourcescopes=[DISPLAY])
assert data['resourcescopes'] == [DISPLAY]
def test_resourcescopes_null_by_default(client, app, db, auth_headers):
data = _mint(client, auth_headers)
assert data['resourcescopes'] is None
def test_bad_resourcescopes_rejected(client, app, db, auth_headers):
resp = client.post('/api/apitokens',
json={'name': 'x', 'scopes': ['geenforce.fetch'],
'resourcescopes': 'not-a-list'},
headers=auth_headers)
assert resp.status_code == 400

View File

@@ -14,7 +14,6 @@ from shopdb.core.models.vendor import Vendor
from plugins.computers.models import Computer, ComputerType
from plugins.printers.models import Printer, PrinterType
from plugins.network.models import NetworkDevice, NetworkDeviceType
from plugins.measuringtools.models import MeasuringTool, MeasuringToolType
def _assettype(name):
@@ -111,32 +110,6 @@ def test_network_search_matches_type_and_vendor(client, db, auth_headers):
assert _numbers(resp) == ['NET-2']
def test_measuringtools_search_matches_type(client, db, auth_headers):
assettype = _assettype('measuring_tool')
caliper = MeasuringToolType(name='Caliper')
_db.session.add(caliper)
_db.session.flush()
typed = Asset(assetnumber='MT-1', assettypeid=assettype.assettypeid)
untyped = Asset(assetnumber='MT-2', name='height gage',
assettypeid=assettype.assettypeid)
_db.session.add_all([typed, untyped])
_db.session.flush()
_db.session.add_all([
MeasuringTool(assetid=typed.assetid,
measuringtooltypeid=caliper.measuringtooltypeid),
MeasuringTool(assetid=untyped.assetid),
])
_db.session.commit()
resp = client.get('/api/measuringtools?search=Caliper', headers=auth_headers)
assert resp.status_code == 200, resp.get_json()
assert _numbers(resp) == ['MT-1']
resp = client.get('/api/measuringtools?search=height', headers=auth_headers)
assert _numbers(resp) == ['MT-2']
def test_core_assets_search_matches_asset_type(client, db, auth_headers):
"""The unified asset list shows a Type column too."""
machine_type = _assettype('machine')

View File

@@ -1,646 +0,0 @@
"""Tests for the measuringtools plugin.
The plugin ships default-disabled, so the shared session app does not register
its blueprint. This module builds its own app, registers the blueprint, runs the
plugin's on_install seeding, and restores the process-wide plugin_manager
singleton afterwards (create_app repoints it) so other test modules are
unaffected - the same snapshot/restore dance used by test_plugin_migrations.
Coverage: derived calibration status (all four buckets + the 30-day boundary),
type CRUD with the in-use delete guard, tool create/update in one merged
payload, and the calibration report shape.
"""
from datetime import date, timedelta
import pytest
from werkzeug.security import generate_password_hash
from shopdb import create_app
from shopdb.extensions import db as _db
from shopdb.plugins import plugin_manager
from plugins.measuringtools.models import (
derive_status, DUESOON_WINDOW_DAYS, STATUS_COLORS)
@pytest.fixture(autouse=True)
def _signed_in(client, auth_headers):
"""Reading needs a permission now, and these tests are about what the
payload says rather than about who may see it. Signing the client in once
beats threading headers through every helper in the module."""
client.environ_base['HTTP_AUTHORIZATION'] = auth_headers['Authorization']
yield
client.environ_base.pop('HTTP_AUTHORIZATION', None)
# =============================================================================
# Pure unit tests: derived status (no app needed)
# =============================================================================
def test_derive_status_unknown_when_no_date():
assert derive_status(None) == 'unknown'
def test_derive_status_overdue_in_past():
assert derive_status(date.today() - timedelta(days=1)) == 'overdue'
def test_derive_status_current_far_future():
assert derive_status(date.today() + timedelta(days=DUESOON_WINDOW_DAYS + 1)) == 'current'
def test_derive_status_duesoon_within_window():
assert derive_status(date.today() + timedelta(days=DUESOON_WINDOW_DAYS - 1)) == 'duesoon'
def test_derive_status_duesoon_on_boundary():
"""Exactly DUESOON_WINDOW_DAYS out is still 'due soon' (inclusive)."""
assert derive_status(date.today() + timedelta(days=DUESOON_WINDOW_DAYS)) == 'duesoon'
def test_derive_status_duesoon_today(auth_headers):
"""Due today is not yet overdue - it falls in the due-soon window."""
assert derive_status(date.today()) == 'duesoon'
# =============================================================================
# API tests (self-contained app with the blueprint registered)
# =============================================================================
@pytest.fixture(scope='module')
def mt_app():
"""A testing app with the measuringtools blueprint registered + seeded."""
saved = (plugin_manager._app, plugin_manager._db, plugin_manager.registry,
plugin_manager.loader, plugin_manager.migration_manager,
plugin_manager._registered_prefixes)
application = create_app('testing')
from plugins.measuringtools.plugin import MeasuringToolsPlugin
plugin = MeasuringToolsPlugin()
pm = application.extensions['plugin_manager']
if plugin.meta.api_prefix not in pm._registered_prefixes:
pm._register_plugin_components(plugin)
with application.app_context():
_db.create_all()
_seed(application, plugin)
yield application
_db.session.remove()
_db.drop_all()
(plugin_manager._app, plugin_manager._db, plugin_manager.registry,
plugin_manager.loader, plugin_manager.migration_manager,
plugin_manager._registered_prefixes) = saved
def _seed(application, plugin):
"""Seed permissions, an admin, a status, and the plugin's own reference data."""
from shopdb.core.models import Permission, User, Role, AssetStatus
Permission.seed()
role = Role(rolename='admin', description='Administrator')
_db.session.add(role)
_db.session.add(AssetStatus(status='In Use', description='In use'))
_db.session.flush()
user = User(username='testadmin', email='admin@test.local',
passwordhash=generate_password_hash('testpass'))
user.roles.append(role)
_db.session.add(user)
_db.session.commit()
# Seeds AssetType 'measuring_tool' + the starter tool types.
plugin.on_install(application)
@pytest.fixture
def client(mt_app):
return mt_app.test_client()
@pytest.fixture
def auth_headers(client):
response = client.post('/api/auth/login',
json={'username': 'testadmin', 'password': 'testpass'})
assert response.status_code == 200, response.get_json()
token = response.get_json()['data']['access_token']
return {'Authorization': f'Bearer {token}'}
def _status_id(client):
return 1 # only status seeded
# -- Type CRUD + in-use guard -------------------------------------------------
def test_starter_types_seeded(client, auth_headers):
response = client.get('/api/measuringtools/types', headers=auth_headers)
assert response.status_code == 200
names = {t['name'] for t in response.get_json()['data']}
assert {'Caliper', 'Micrometer', 'Thread Gage', 'Other'} <= names
def test_type_create_requires_auth(client):
# This module signs the client in for every test; this one is about what
# happens when nobody is signed in, so it takes that back.
client.environ_base.pop('HTTP_AUTHORIZATION', None)
response = client.post('/api/measuringtools/types', json={'name': 'Nope'})
assert response.status_code == 401
def test_type_crud_lifecycle(client, auth_headers):
created = client.post('/api/measuringtools/types', headers=auth_headers,
json={'name': 'Pin Gage', 'description': 'Pin gage set',
'color': '#123456'})
assert created.status_code == 201, created.get_json()
type_id = created.get_json()['data']['measuringtooltypeid']
updated = client.put(f'/api/measuringtools/types/{type_id}', headers=auth_headers,
json={'description': 'Updated'})
assert updated.status_code == 200
assert updated.get_json()['data']['description'] == 'Updated'
dup = client.post('/api/measuringtools/types', headers=auth_headers,
json={'name': 'Pin Gage'})
assert dup.status_code == 409
deleted = client.delete(f'/api/measuringtools/types/{type_id}', headers=auth_headers)
assert deleted.status_code == 200
def test_type_delete_blocked_when_in_use(client, auth_headers):
type_created = client.post('/api/measuringtools/types', headers=auth_headers,
json={'name': 'Depth Gage'})
type_id = type_created.get_json()['data']['measuringtooltypeid']
tool_created = client.post('/api/measuringtools', headers=auth_headers,
json={'assetnumber': 'MT-INUSE-1',
'measuringtooltypeid': type_id,
'statusid': _status_id(client)})
assert tool_created.status_code == 201, tool_created.get_json()
blocked = client.delete(f'/api/measuringtools/types/{type_id}', headers=auth_headers)
assert blocked.status_code == 409
assert 'still use this type' in blocked.get_json()['data']['error']['message']
# -- Tool create/update merged payload ----------------------------------------
def test_tool_create_and_get_merged(client, auth_headers):
caliper = client.get('/api/measuringtools/types').get_json()['data']
caliper_id = next(t['measuringtooltypeid'] for t in caliper if t['name'] == 'Caliper')
created = client.post('/api/measuringtools', headers=auth_headers, json={
'assetnumber': 'MT-001',
'name': 'Bench caliper',
'serialnumber': 'SN-CAL-9',
'gaugelabreference': 'GL-42',
'measuringtooltypeid': caliper_id,
'statusid': _status_id(client),
'calibrationintervaldays': 365,
'lastcalibrationdate': '2026-01-01',
'nextcalibrationdate': '2027-01-01',
'calibrationprovider': 'Metro Cal Lab',
})
assert created.status_code == 201, created.get_json()
payload = created.get_json()['data']
# Asset core fields at the top level, extension nested.
assert payload['assetnumber'] == 'MT-001'
assert payload['serialnumber'] == 'SN-CAL-9'
assert payload['gaugelabreference'] == 'GL-42'
ext = payload['measuringtool']
assert ext['measuringtooltypename'] == 'Caliper'
assert ext['calibrationprovider'] == 'Metro Cal Lab'
assert ext['calibrationstatus'] == 'current'
tool_id = ext['measuringtoolid']
fetched = client.get(f'/api/measuringtools/{tool_id}', headers=auth_headers)
assert fetched.status_code == 200
assert fetched.get_json()['data']['measuringtool']['measuringtoolid'] == tool_id
def test_tool_payload_carries_location_code_and_name(mt_app, client, auth_headers):
"""A tool at an operation location exposes locationcode (leading token)
and locationname so a printed label can encode the inspection operation."""
with mt_app.app_context():
from shopdb.core.models import Location
location = Location(locationname='0615 Blisk Inspection')
_db.session.add(location)
_db.session.commit()
location_id = location.locationid
created = client.post('/api/measuringtools', headers=auth_headers, json={
'assetnumber': 'MT-LOC-1', 'statusid': _status_id(client),
'locationid': location_id,
})
assert created.status_code == 201, created.get_json()
payload = created.get_json()['data']
assert payload['locationname'] == '0615 Blisk Inspection'
assert payload['locationcode'] == '0615'
def test_tool_payload_location_code_none_when_unplaced(client, auth_headers):
"""A tool with no location degrades gracefully: locationcode is None."""
created = client.post('/api/measuringtools', headers=auth_headers, json={
'assetnumber': 'MT-NOLOC-1', 'statusid': _status_id(client),
})
assert created.status_code == 201, created.get_json()
assert created.get_json()['data']['locationcode'] is None
def test_tool_update_merged_payload(client, auth_headers):
created = client.post('/api/measuringtools', headers=auth_headers, json={
'assetnumber': 'MT-UPD-1', 'statusid': _status_id(client),
'nextcalibrationdate': '2030-01-01',
})
tool_id = created.get_json()['data']['measuringtool']['measuringtoolid']
updated = client.put(f'/api/measuringtools/{tool_id}', headers=auth_headers, json={
'name': 'Renamed tool', # asset core field
'calibrationprovider': 'In-house', # extension field
'nextcalibrationdate': str(date.today() - timedelta(days=5)), # -> overdue
})
assert updated.status_code == 200, updated.get_json()
data = updated.get_json()['data']
assert data['name'] == 'Renamed tool'
assert data['measuringtool']['calibrationprovider'] == 'In-house'
assert data['measuringtool']['calibrationstatus'] == 'overdue'
def test_tool_create_duplicate_assetnumber_conflicts(client, auth_headers):
body = {'assetnumber': 'MT-DUP', 'statusid': _status_id(client)}
first = client.post('/api/measuringtools', headers=auth_headers, json=body)
assert first.status_code == 201
second = client.post('/api/measuringtools', headers=auth_headers, json=body)
assert second.status_code == 409
def test_list_filter_by_calibrationstatus(client, auth_headers):
client.post('/api/measuringtools', headers=auth_headers, json={
'assetnumber': 'MT-OVERDUE-1', 'statusid': _status_id(client),
'nextcalibrationdate': str(date.today() - timedelta(days=10)),
})
response = client.get('/api/measuringtools?calibrationstatus=overdue', headers=auth_headers)
assert response.status_code == 200
rows = response.get_json()['data']
assert rows, 'expected at least one overdue tool'
assert all(r['measuringtool']['calibrationstatus'] == 'overdue' for r in rows)
# -- Maintenance reference identifier -----------------------------------------
def test_maintenancereference_roundtrip(client, auth_headers):
"""Maintenance reference persists on create and update."""
created = client.post('/api/measuringtools', headers=auth_headers, json={
'assetnumber': 'MT-MNT-1', 'statusid': _status_id(client),
'maintenancereference': 'MNT-9',
})
assert created.status_code == 201, created.get_json()
assert created.get_json()['data']['maintenancereference'] == 'MNT-9'
tool_id = created.get_json()['data']['measuringtool']['measuringtoolid']
updated = client.put(f'/api/measuringtools/{tool_id}', headers=auth_headers,
json={'maintenancereference': 'MNT-10'})
assert updated.status_code == 200
assert updated.get_json()['data']['maintenancereference'] == 'MNT-10'
# -- Global search ------------------------------------------------------------
def _mt_search_hits(client, auth_headers, term):
resp = client.get(f'/api/search?q={term}', headers=auth_headers)
assert resp.status_code == 200, resp.get_json()
return [r for r in resp.get_json()['data']['results']
if r.get('type') == 'measuring_tool']
def test_search_finds_tool_by_assetnumber(mt_app, client, auth_headers, monkeypatch):
"""A measuring tool appears in global search by asset number, routed right."""
client.post('/api/measuringtools', headers=auth_headers, json={
'assetnumber': 'MT-SEARCH-AN', 'statusid': _status_id(client)})
pm = mt_app.extensions['plugin_manager']
monkeypatch.setattr(pm.registry, 'is_enabled', lambda name: True)
hits = _mt_search_hits(client, auth_headers, 'MT-SEARCH-AN')
assert hits
assert hits[0]['url'].startswith('/measuringtools/')
def test_search_finds_tool_by_gaugelabreference(mt_app, client, auth_headers, monkeypatch):
"""A gage-tag lookup by gaugelabreference finds the tool."""
client.post('/api/measuringtools', headers=auth_headers, json={
'assetnumber': 'MT-SEARCH-GL', 'statusid': _status_id(client),
'gaugelabreference': 'GLREF-778'})
pm = mt_app.extensions['plugin_manager']
monkeypatch.setattr(pm.registry, 'is_enabled', lambda name: True)
hits = _mt_search_hits(client, auth_headers, 'GLREF-778')
assert hits
assert hits[0]['url'].startswith('/measuringtools/')
def test_search_excludes_tools_when_domain_disabled(mt_app, client, auth_headers, monkeypatch):
"""search_measuring_tool_enabled=false hides measuring tools from search."""
from shopdb.core.models import Setting
client.post('/api/measuringtools', headers=auth_headers, json={
'assetnumber': 'MT-SEARCH-OFF', 'statusid': _status_id(client),
'gaugelabreference': 'GLREF-OFF-1'})
pm = mt_app.extensions['plugin_manager']
monkeypatch.setattr(pm.registry, 'is_enabled', lambda name: True)
Setting.set('search_measuring_tool_enabled', False, valuetype='boolean',
category='search')
try:
assert _mt_search_hits(client, auth_headers, 'MT-SEARCH-OFF') == []
assert _mt_search_hits(client, auth_headers, 'GLREF-OFF-1') == []
finally:
Setting.set('search_measuring_tool_enabled', True, valuetype='boolean',
category='search')
# -- Report shape -------------------------------------------------------------
def test_calibration_report_shape(client, auth_headers):
response = client.get('/api/measuringtools/report/calibration', headers=auth_headers)
assert response.status_code == 200
data = response.get_json()['data']
assert set(data['counts']) == {'overdue', 'duesoon', 'current', 'unknown'}
assert set(data['buckets']) == {'overdue', 'duesoon', 'current', 'unknown'}
# Buckets and counts agree.
for key, rows in data['buckets'].items():
assert data['counts'][key] == len(rows)
assert 'statuscolors' in data
# -- Core integration: asset serialization ------------------------------------
def _caliper_id(client):
types = client.get('/api/measuringtools/types').get_json()['data']
return next(t['measuringtooltypeid'] for t in types if t['name'] == 'Caliper')
def test_asset_todict_carries_measuringtool_typedata_and_pluginid(mt_app, client, auth_headers):
"""Asset.to_dict resolves the measuringtool extension (typedata + pluginid)."""
from shopdb.core.models import Asset
created = client.post('/api/measuringtools', headers=auth_headers, json={
'assetnumber': 'MT-TD-1', 'statusid': _status_id(client),
'measuringtooltypeid': _caliper_id(client),
})
assert created.status_code == 201, created.get_json()
tool_id = created.get_json()['data']['measuringtool']['measuringtoolid']
assetid = created.get_json()['data']['assetid']
with mt_app.app_context():
asset = _db.session.get(Asset, assetid)
result = asset.to_dict(include_type_data=True)
assert result['pluginid'] == tool_id
assert result['typedata']['measuringtooltypename'] == 'Caliper'
assert result['typedata']['measuringtoolid'] == tool_id
# -- Core integration: shop-floor map -----------------------------------------
def test_map_lists_measuringtool_subtypes(client, auth_headers):
"""The map filter dropdown carries a Measuring Tool subtype list with color."""
response = client.get('/api/assets/map', headers=auth_headers)
assert response.status_code == 200, response.get_json()
subtypes = response.get_json()['data']['filters']['subtypes']
assert 'Measuring Tool' in subtypes
names = {s['name'] for s in subtypes['Measuring Tool']}
assert 'Caliper' in names
assert all('color' in s for s in subtypes['Measuring Tool'])
def test_map_honors_measuringtool_subtype_filter(client, auth_headers):
"""?assettype=measuring_tool&subtype=<id> returns only that subtype's tools."""
types = client.get('/api/measuringtools/types').get_json()['data']
caliper_id = next(t['measuringtooltypeid'] for t in types if t['name'] == 'Caliper')
micrometer_id = next(t['measuringtooltypeid'] for t in types if t['name'] == 'Micrometer')
client.post('/api/measuringtools', headers=auth_headers, json={
'assetnumber': 'MT-MAP-CAL', 'statusid': _status_id(client),
'measuringtooltypeid': caliper_id, 'mapx': 10, 'mapy': 20})
client.post('/api/measuringtools', headers=auth_headers, json={
'assetnumber': 'MT-MAP-MIC', 'statusid': _status_id(client),
'measuringtooltypeid': micrometer_id, 'mapx': 30, 'mapy': 40})
response = client.get(
f'/api/assets/map?assettype=measuring_tool&subtype={caliper_id}', headers=auth_headers)
assert response.status_code == 200, response.get_json()
numbers = {a['assetnumber'] for a in response.get_json()['data']['assets']}
assert 'MT-MAP-CAL' in numbers
assert 'MT-MAP-MIC' not in numbers
def test_map_item_carries_measuringtool_typedata(client, auth_headers):
"""A mapped tool's item carries the extension typedata for marker coloring."""
client.post('/api/measuringtools', headers=auth_headers, json={
'assetnumber': 'MT-MAP-TD', 'statusid': _status_id(client),
'measuringtooltypeid': _caliper_id(client), 'mapx': 55, 'mapy': 66})
response = client.get('/api/assets/map?assettype=measuring_tool', headers=auth_headers)
item = next(a for a in response.get_json()['data']['assets']
if a['assetnumber'] == 'MT-MAP-TD')
assert item['typedata']['measuringtooltypename'] == 'Caliper'
# -- Core integration: dashboard ----------------------------------------------
def test_dashboard_counts_include_measuringtools(client, auth_headers):
"""Dashboard total and counts include active measuring tools."""
before = client.get('/api/dashboard').get_json()['data']
client.post('/api/measuringtools', headers=auth_headers, json={
'assetnumber': 'MT-DASH-1', 'statusid': _status_id(client)})
after = client.get('/api/dashboard').get_json()['data']
assert after['counts']['measuringtools'] == before['counts']['measuringtools'] + 1
assert after['totalmeasuringtool'] == before['totalmeasuringtool'] + 1
assert after['counts']['total'] == before['counts']['total'] + 1
# -- Map overlay endpoint (ADR-010) -------------------------------------------
def test_map_overlay_shape_and_derivation(client, auth_headers):
"""map-overlay returns per-asset derived calibration status + color."""
created = client.post('/api/measuringtools', headers=auth_headers, json={
'assetnumber': 'MT-OVL-1', 'statusid': _status_id(client),
'nextcalibrationdate': str(date.today() - timedelta(days=3))}) # overdue
assetid = created.get_json()['data']['assetid']
response = client.get('/api/measuringtools/map-overlay', headers=auth_headers)
assert response.status_code == 200, response.get_json()
rows = response.get_json()['data']
# ADR-010 overlay contract: [{assetid, color, label}], only due/overdue tools.
row = next(r for r in rows if r['assetid'] == assetid)
assert set(row) == {'assetid', 'color', 'label'}
assert row['label'] == 'Overdue'
assert row['color'] == STATUS_COLORS['overdue']
def test_map_overlay_excludes_inactive(client, auth_headers):
"""A soft-deleted tool drops out of the overlay."""
created = client.post('/api/measuringtools', headers=auth_headers, json={
'assetnumber': 'MT-OVL-DEL', 'statusid': _status_id(client)})
tool_id = created.get_json()['data']['measuringtool']['measuringtoolid']
assetid = created.get_json()['data']['assetid']
client.delete(f'/api/measuringtools/{tool_id}', headers=auth_headers)
rows = client.get('/api/measuringtools/map-overlay').get_json()['data']
assert all(r['assetid'] != assetid for r in rows)
# -- Presentation route token (ADR-010) ---------------------------------------
def test_asset_presentation_route_token():
"""Presentation route links through the by-asset resolver (only {assetid})."""
from plugins.measuringtools.plugin import MeasuringToolsPlugin
entries = MeasuringToolsPlugin().get_asset_presentation()
entry = next(e for e in entries if e['assettype'] == 'measuring_tool')
assert entry['route'] == '/measuringtools/by-asset/{assetid}'
# --- a date that is not a date -----------------------------------------------
#
# _parse_date used to swallow the ValueError and return None, so a typo in the
# calibration date answered 200 "Measuring tool updated", stored NULL, flipped
# the tool to 'unknown', and dropped it out of the calibration report and the
# map overlay. Clearing a date and mistyping one are different intents and now
# get different answers.
def test_a_mistyped_calibration_date_is_refused_not_stored_as_null(
client, auth_headers):
created = client.post('/api/measuringtools', headers=auth_headers, json={
'assetnumber': 'MT-DATE-1',
'name': 'Date guard caliper',
'statusid': _status_id(client),
'nextcalibrationdate': '2027-01-01',
})
assert created.status_code == 201, created.get_json()
toolid = created.get_json()['data']['measuringtool']['measuringtoolid']
refused = client.put(f'/api/measuringtools/{toolid}', headers=auth_headers,
json={'nextcalibrationdate': 'not-a-date'})
assert refused.status_code == 400, refused.get_json()
assert 'nextcalibrationdate' in \
refused.get_json()['data']['error']['message']
# The stored date is untouched - the whole point of refusing.
after = client.get(f'/api/measuringtools/{toolid}',
headers=auth_headers).get_json()['data']
assert after['measuringtool']['nextcalibrationdate'] == '2027-01-01'
assert after['measuringtool']['calibrationstatus'] != 'unknown'
def test_an_explicitly_empty_calibration_date_still_clears_it(client,
auth_headers):
"""Clearing is a real intent and must keep working, or the fix above just
trades one broken behaviour for another."""
created = client.post('/api/measuringtools', headers=auth_headers, json={
'assetnumber': 'MT-DATE-2',
'name': 'Clearable caliper',
'statusid': _status_id(client),
'nextcalibrationdate': '2027-01-01',
})
toolid = created.get_json()['data']['measuringtool']['measuringtoolid']
cleared = client.put(f'/api/measuringtools/{toolid}', headers=auth_headers,
json={'nextcalibrationdate': ''})
assert cleared.status_code == 200, cleared.get_json()
after = client.get(f'/api/measuringtools/{toolid}',
headers=auth_headers).get_json()['data']
assert after['measuringtool']['nextcalibrationdate'] is None
def test_a_mistyped_date_is_refused_at_create_too(client, auth_headers):
refused = client.post('/api/measuringtools', headers=auth_headers, json={
'assetnumber': 'MT-DATE-3',
'name': 'Never created',
'statusid': _status_id(client),
'lastcalibrationdate': '01/02/2026',
})
assert refused.status_code == 400, refused.get_json()
listed = client.get('/api/measuringtools?search=MT-DATE-3',
headers=auth_headers).get_json()['data']
assert listed == [], 'nothing may be created by a refused request'
# --- an audited field holding a date must not kill the request ---------------
#
# PUT /api/machines/<id> returned 500 and rolled the whole edit back for any
# machine with a maintenance date: the route builds
# {'old': getattr(row, field)} straight off the model, and json.dumps raises on
# a date. A rename that never touched the date failed too. The audit log
# refusing to record a change must not be what stops the change.
def test_an_edit_is_saved_even_when_an_audited_field_holds_a_date(
client, auth_headers):
created = client.post('/api/measuringtools', headers=auth_headers, json={
'assetnumber': 'MT-AUDIT-1',
'name': 'Before rename',
'statusid': _status_id(client),
'lastcalibrationdate': '2026-01-15',
'nextcalibrationdate': '2027-01-15',
})
assert created.status_code == 201, created.get_json()
toolid = created.get_json()['data']['measuringtool']['measuringtoolid']
# A rename that does not touch the dates. The date is only in the CHANGES
# dict because the interval moves with it.
renamed = client.put(f'/api/measuringtools/{toolid}', headers=auth_headers,
json={'name': 'After rename',
'nextcalibrationdate': '2028-01-15'})
assert renamed.status_code == 200, renamed.get_json()
after = client.get(f'/api/measuringtools/{toolid}',
headers=auth_headers).get_json()['data']
assert after['name'] == 'After rename'
assert after['measuringtool']['nextcalibrationdate'] == '2028-01-15'
# --- a derived filter must page over the MATCHES ----------------------------
#
# calibrationstatus was applied to the rows of the current page, so a request
# for 'overdue' returned whatever matched on that page while the total counted
# every tool. With one overdue tool in sixteen: page 1 empty, total 16, eight
# pages, and the row itself on page 3. The filter hid matching rows and
# miscounted the rest.
def test_a_calibration_filter_pages_over_the_matches_not_the_page(
client, auth_headers):
from datetime import date, timedelta
overdue = (date.today() - timedelta(days=5)).isoformat()
current = (date.today() + timedelta(days=400)).isoformat()
# One overdue tool, well past the first page of a small page size.
for index in range(6):
created = client.post('/api/measuringtools', headers=auth_headers, json={
'assetnumber': 'MT-PAGE-%02d' % index,
'name': 'Paged tool %d' % index,
'statusid': _status_id(client),
'nextcalibrationdate': overdue if index == 5 else current,
})
assert created.status_code == 201, created.get_json()
# Scoped with search as well, so earlier tests in this module cannot
# change the expected set - the point is paging over matches, not how many
# overdue tools the whole fixture happens to hold.
response = client.get(
'/api/measuringtools?calibrationstatus=overdue&search=MT-PAGE&perpage=2',
headers=auth_headers)
assert response.status_code == 200, response.get_json()
body = response.get_json()
rows = body['data']
pagination = body['meta']['pagination']
assert [row['assetnumber'] for row in rows] == ['MT-PAGE-05'], \
'the overdue tool must be on page 1 of a filtered list'
assert pagination['total'] == 1, \
'the total counts the MATCHES, not every tool'
assert pagination['totalpages'] == 1
def test_an_unfiltered_list_still_pages_normally(client, auth_headers):
for index in range(3):
client.post('/api/measuringtools', headers=auth_headers, json={
'assetnumber': 'MT-PLAIN-%02d' % index,
'name': 'Plain tool %d' % index,
'statusid': _status_id(client),
})
response = client.get('/api/measuringtools?perpage=2', headers=auth_headers)
assert response.status_code == 200
pagination = response.get_json()['meta']['pagination']
assert pagination['perpage'] == 2
assert pagination['total'] >= 3

View File

@@ -6,7 +6,7 @@ must expose it as 'imageurl' (the field name machines established, consumed by
the detail-page heroes and the machine badge). When no model is linked, the
field must be absent so the frontend v-if renders nothing.
Network devices and measuring tools have no model link, so they are out of
Network devices have no model link, so they are out of
scope here by design.
"""

View File

@@ -0,0 +1,93 @@
"""alertscope is TEXT in the database, a dict in Python.
Migration 0006 ships the column as TEXT because a site still runs MySQL 5.6,
which has no JSON type. The model declared db.JSON, so create-all built a column
the migration path never produces and the two disagreed about the same column.
"""
import json
from sqlalchemy import Text, inspect
from plugins.notifications.models.notification import Notification
def test_column_type_matches_the_migration(app):
"""The model's storage type must be the one migration 0006 creates."""
column = inspect(Notification).columns['alertscope']
assert isinstance(column.type.impl_instance
if hasattr(column.type, 'impl_instance')
else column.type.impl, Text)
def test_ddl_has_no_json_column(app, db):
"""MySQL 5.6 fails on a JSON column with a bare syntax error, so the
emitted DDL must not contain one."""
from sqlalchemy.schema import CreateTable
ddl = str(CreateTable(Notification.__table__).compile(db.engine))
alertscope_line = [line for line in ddl.splitlines()
if 'alertscope' in line][0]
assert 'JSON' not in alertscope_line.upper()
def test_dict_round_trips(app, db):
"""A scope assigned as a dict reads back as a dict."""
row = Notification(notification='fleet alert', alertfleet=True,
alerttimeout=0,
alertscope={'hostname': ['BAY-01', 'BAY-02']})
db.session.add(row)
db.session.commit()
notificationid = row.notificationid
db.session.expire_all()
reread = db.session.get(Notification, notificationid)
assert reread.alertscope == {'hostname': ['BAY-01', 'BAY-02']}
def test_stored_value_is_json_text(app, db):
"""What lands in the column is a JSON string, readable by a site that
queries the table directly."""
row = Notification(notification='fleet alert', alertfleet=True,
alerttimeout=0, alertscope={'computertype': 'CMM PC'})
db.session.add(row)
db.session.commit()
raw = db.session.execute(
db.text('SELECT alertscope FROM notifications WHERE notificationid = :i'),
{'i': row.notificationid}).scalar()
assert isinstance(raw, str)
assert json.loads(raw) == {'computertype': 'CMM PC'}
def test_null_scope_stays_null(app, db):
"""No scope means the whole fleet; it must not become the string 'null'."""
row = Notification(notification='fleet alert', alertfleet=True,
alerttimeout=0, alertscope=None)
db.session.add(row)
db.session.commit()
raw = db.session.execute(
db.text('SELECT alertscope FROM notifications WHERE notificationid = :i'),
{'i': row.notificationid}).scalar()
assert raw is None
assert row.alertscope is None
def test_unparseable_text_is_handed_back_raw(app, db):
"""A hand-edited row must be visible and fixable, not an exception on read.
The endpoints matcher already treats a non-dict scope as matching nothing.
"""
row = Notification(notification='fleet alert', alertfleet=True,
alerttimeout=0)
db.session.add(row)
db.session.commit()
db.session.execute(
db.text('UPDATE notifications SET alertscope = :v '
'WHERE notificationid = :i'),
{'v': 'not json at all', 'i': row.notificationid})
db.session.commit()
db.session.expire_all()
reread = db.session.get(Notification, row.notificationid)
assert reread.alertscope == 'not json at all'

View File

@@ -0,0 +1,101 @@
"""Fleet alert fields on notification create/update.
alertfleet and alerttimeout are NOT NULL in migration 0006, so a null from the
form is coerced to the column default rather than reaching the database.
alertscope must be an object whose keys the endpoint matcher understands; a
typo or a non-object is refused at the API instead of being stored and
silently re-interpreted at delivery time.
"""
from plugins.notifications.models import Notification, NotificationType
def _type(db):
ntype = NotificationType(typename='Fleet Alert', typecolor='#d93f3f',
isactive=True)
db.session.add(ntype)
db.session.commit()
return ntype.notificationtypeid
def _create(client, headers, typeid, **fields):
body = {'notification': 'Line 3 is down', 'notificationtypeid': typeid}
body.update(fields)
return client.post('/api/notifications', json=body, headers=headers)
def test_null_timeout_and_fleet_are_coerced_to_defaults(client, db, auth_headers):
typeid = _type(db)
resp = _create(client, auth_headers, typeid, alertfleet=None,
alerttimeout=None, alertscope=None)
assert resp.status_code == 201, resp.get_json()
row = db.session.get(Notification, resp.get_json()['data']['notificationid'])
assert row.alertfleet is False
assert row.alerttimeout == 0
assert row.alertscope is None
def test_update_coerces_null_timeout(client, db, auth_headers):
typeid = _type(db)
created = _create(client, auth_headers, typeid, alertfleet=True,
alerttimeout=60).get_json()['data']
resp = client.put(f"/api/notifications/{created['notificationid']}",
json={'alerttimeout': None, 'alertfleet': None},
headers=auth_headers)
assert resp.status_code == 200, resp.get_json()
row = db.session.get(Notification, created['notificationid'])
assert row.alerttimeout == 0
assert row.alertfleet is False
def test_a_valid_scope_is_stored(client, db, auth_headers):
typeid = _type(db)
resp = _create(client, auth_headers, typeid, alertfleet=True,
alertscope={'hostname': ['BAY-01', 'BAY-02'],
'computertype': 'CMM PC'})
assert resp.status_code == 201, resp.get_json()
assert resp.get_json()['data']['alertscope']['computertype'] == 'CMM PC'
def test_an_empty_scope_means_whole_fleet(client, db, auth_headers):
typeid = _type(db)
resp = _create(client, auth_headers, typeid, alertfleet=True, alertscope='')
assert resp.status_code == 201, resp.get_json()
assert resp.get_json()['data']['alertscope'] is None
def test_unknown_scope_key_is_refused(client, db, auth_headers):
typeid = _type(db)
resp = _create(client, auth_headers, typeid, alertfleet=True,
alertscope={'hostnam': 'BAY-01'})
assert resp.status_code == 400
assert 'hostnam' in resp.get_json()['data']['error']['message']
assert Notification.query.count() == 0
def test_non_object_scope_is_refused(client, db, auth_headers):
typeid = _type(db)
for scope in (['BAY-01'], 'BAY-01', '{not json', 5):
resp = _create(client, auth_headers, typeid, alertfleet=True,
alertscope=scope)
assert resp.status_code == 400, scope
assert Notification.query.count() == 0
def test_update_refuses_bad_scope_and_keeps_the_old_one(client, db, auth_headers):
typeid = _type(db)
created = _create(client, auth_headers, typeid, alertfleet=True,
alertscope={'hostname': 'BAY-01'}).get_json()['data']
resp = client.put(f"/api/notifications/{created['notificationid']}",
json={'alertscope': {'building': 'X'}},
headers=auth_headers)
assert resp.status_code == 400
db.session.expire_all()
row = db.session.get(Notification, created['notificationid'])
assert row.alertscope == {'hostname': 'BAY-01'}
def test_bad_timeout_is_refused(client, db, auth_headers):
typeid = _type(db)
assert _create(client, auth_headers, typeid, alerttimeout=-1).status_code == 400
assert _create(client, auth_headers, typeid, alerttimeout='soon').status_code == 400

View File

@@ -1,654 +0,0 @@
"""Printedparts ledger + badge tests.
The invariants that make the plugin trustworthy: itemcode minting, the
single-commit cache==ledger rule, the below-zero guard, quantity edits
forced through the ledger, and the badge contract (SSO digits, PayNo
wrap, unknown-badge policy).
"""
import pytest
from shopdb.api import db
from shopdb.core.models import Setting
from plugins.printedparts.models import PrintedItem, PrintedItemTransaction
@pytest.fixture
def item(app, db):
with app.app_context():
row = PrintedItem(itemcode='3DP-9001', itemname='Test clip',
quantityonhand=0, lowstockthreshold=5)
db.session.add(row)
db.session.commit()
yield row.printeditemid
@pytest.fixture
def directory_employee(app):
with app.app_context():
from plugins.employees.models import DirectoryEmployee
if not db.session.get(DirectoryEmployee, 502000001):
db.session.add(DirectoryEmployee(
sso=502000001, firstname='Pat', lastname='Printer'))
db.session.commit()
return '502000001'
def test_create_mints_itemcode(client, auth_headers):
response = client.post('/api/printedparts/items', json={'itemname': 'Bracket'},
headers=auth_headers)
assert response.status_code == 201
data = response.get_json()['data']
assert data['itemcode'] == f"3DP{data['printeditemid']:04d}"
assert data['quantityonhand'] == 0
def test_update_refuses_quantity(client, auth_headers, item):
response = client.put(f'/api/printedparts/items/{item}',
json={'quantityonhand': 50}, headers=auth_headers)
assert response.status_code == 400
def test_restock_writes_ledger_and_cache(client, auth_headers, app, item,
directory_employee):
response = client.post(f'/api/printedparts/items/{item}/restock',
json={'quantity': 10, 'badge': directory_employee},
headers=auth_headers)
assert response.status_code == 200
assert response.get_json()['data']['quantityonhand'] == 10
with app.app_context():
rows = PrintedItemTransaction.query.filter_by(printeditemid=item).all()
assert len(rows) == 1
assert rows[0].transactiontype == 'restock'
assert rows[0].quantitychange == 10
assert rows[0].employeename == 'Pat Printer'
cached = db.session.get(PrintedItem, item).quantityonhand
assert cached == sum(r.quantitychange for r in rows)
def test_payno_badge_shape_resolves(client, auth_headers, item,
directory_employee):
response = client.post(f'/api/printedparts/items/{item}/restock',
json={'quantity': 1,
'badge': f'0{directory_employee}BZ'},
headers=auth_headers)
assert response.status_code == 200
def test_adjust_requires_reason_and_floors_at_zero(client, auth_headers, item,
directory_employee):
no_reason = client.post(f'/api/printedparts/items/{item}/adjust',
json={'quantitychange': -1,
'badge': directory_employee},
headers=auth_headers)
assert no_reason.status_code == 400
below_zero = client.post(f'/api/printedparts/items/{item}/adjust',
json={'quantitychange': -1, 'reason': 'test',
'badge': directory_employee},
headers=auth_headers)
assert below_zero.status_code == 400
def test_unknown_badge_denied_then_allowed_by_policy(client, auth_headers, app,
item):
denied = client.post(f'/api/printedparts/items/{item}/restock',
json={'quantity': 1, 'badge': '999999999'},
headers=auth_headers)
assert denied.status_code == 422
with app.app_context():
Setting.set('printedparts_unknown_badge', 'allow',
valuetype='string', category='printedparts')
db.session.commit()
try:
allowed = client.post(f'/api/printedparts/items/{item}/restock',
json={'quantity': 1, 'badge': '999999999'},
headers=auth_headers)
assert allowed.status_code == 200
assert allowed.get_json()['data']['quantityonhand'] == 1
finally:
with app.app_context():
Setting.set('printedparts_unknown_badge', 'deny',
valuetype='string', category='printedparts')
db.session.commit()
def test_anonymous_cannot_mutate(client, item):
assert client.post('/api/printedparts/items',
json={'itemname': 'X'}).status_code == 401
assert client.post(f'/api/printedparts/items/{item}/restock',
json={'quantity': 1, 'badge': '1'}).status_code == 401
KIOSK_TOKEN = 'kiosk-device-token-for-tests'
# The header a provisioned screen sends. A module constant rather than the
# fixture's return value, so a test does not have to name the fixture just to
# reach the header.
KIOSK_HEADERS = {'X-Kiosk-Token': KIOSK_TOKEN}
@pytest.fixture(autouse=True)
def kiosk(app, db):
"""Provision the kiosk the way an administrator would, and hand back the
header a provisioned screen sends. Without this every kiosk route 404s,
which is the point: the routes are the only unauthenticated write in the
product, so they are off until a site opts in."""
with app.app_context():
Setting.set('printedparts_kiosk_enabled', 'true')
Setting.set('printedparts_kiosk_token', KIOSK_TOKEN)
db.session.commit()
return KIOSK_HEADERS
def test_catalog_reads_require_view_permission(client, member_headers, item):
"""Browsing the catalog is printedparts.view-gated; a PROVISIONED kiosk
reads without an account."""
assert client.get('/api/printedparts/items').status_code == 401
assert client.get(f'/api/printedparts/items/{item}').status_code == 401
assert client.get('/api/printedparts/items',
headers=member_headers).status_code == 403
assert client.get('/api/printedparts/kiosk/item/3DP-9001',
headers=KIOSK_HEADERS).status_code == 200
def test_member_without_permission_gets_403(client, member_headers, item):
"""Authentication alone is not authorization: a role-less user is denied."""
assert client.post('/api/printedparts/items', json={'itemname': 'X'},
headers=member_headers).status_code == 403
assert client.post(f'/api/printedparts/items/{item}/restock',
json={'quantity': 1, 'badge': '1'},
headers=member_headers).status_code == 403
def test_kiosk_take_is_decrement_only(client, auth_headers, app, item,
directory_employee, kiosk):
"""A provisioned kiosk needs no account but only ever decrements stock."""
itemcode = '3DP-9001'
stocked = client.post(f'/api/printedparts/items/{item}/restock',
json={'quantity': 5, 'badge': directory_employee},
headers=auth_headers)
assert stocked.status_code == 200
lookup = client.get(f'/api/printedparts/kiosk/item/{itemcode}', headers={**auth_headers, **KIOSK_HEADERS})
assert lookup.status_code == 200
take = client.post('/api/printedparts/kiosk/take', json={
'itemcode': itemcode, 'badge': directory_employee, 'quantity': 2},
headers={**auth_headers, **KIOSK_HEADERS})
assert take.status_code == 200, take.get_json()
assert take.get_json()['data']['quantityonhand'] == 3
too_many = client.post('/api/printedparts/kiosk/take', json={
'itemcode': itemcode, 'badge': directory_employee, 'quantity': 99},
headers={**auth_headers, **KIOSK_HEADERS})
assert too_many.status_code == 400
unknown = client.post('/api/printedparts/kiosk/take', json={
'itemcode': itemcode, 'badge': '111111111', 'quantity': 1},
headers={**auth_headers, **KIOSK_HEADERS})
assert unknown.status_code == 422
with app.app_context():
rows = PrintedItemTransaction.query.filter_by(
printeditemid=item, transactiontype='take').all()
assert len(rows) == 1
assert rows[0].quantitychange == -2
assert rows[0].employeename == 'Pat Printer'
cached = db.session.get(PrintedItem, item).quantityonhand
ledgersum = sum(r.quantitychange for r in
PrintedItemTransaction.query.filter_by(
printeditemid=item).all())
assert cached == ledgersum
def test_lowstock_alert_fires_on_crossing_only(client, auth_headers, app, item,
directory_employee, monkeypatch):
"""One alert when stock CROSSES the threshold downward; restocking above
rearms it; staying below does not re-fire."""
sent = []
import plugins.printedparts.api.routes as printedparts_routes
monkeypatch.setattr(
printedparts_routes, '_send_lowstock_alert',
lambda alerted_item: sent.append(alerted_item.itemcode))
def restock(quantity):
return client.post(f'/api/printedparts/items/{item}/restock',
json={'quantity': quantity,
'badge': directory_employee},
headers=auth_headers)
def take(quantity):
return client.post('/api/printedparts/kiosk/take',
json={'itemcode': '3DP-9001',
'badge': directory_employee,
'quantity': quantity}, headers={**auth_headers, **KIOSK_HEADERS})
restock(10) # 10 on hand, threshold 5
assert take(3).status_code == 200 # 7: above threshold, no alert
assert sent == []
assert take(3).status_code == 200 # 4: CROSSES 5 -> one alert
assert sent == ['3DP-9001']
assert take(2).status_code == 200 # 2: still below, no re-fire
assert sent == ['3DP-9001']
restock(20) # 22: rearmed
assert take(18).status_code == 200 # 4: crosses again -> second alert
assert sent == ['3DP-9001', '3DP-9001']
def test_alert_recipients_merge_users_and_freetext(client, auth_headers, app,
item, directory_employee,
monkeypatch):
"""Selected shopdb users' account emails merge with the free-text list,
deduped; inactive users are skipped."""
import shopdb.api as contract_surface
captured = {}
monkeypatch.setattr(contract_surface, 'send_email',
lambda to, subject, html, text=None:
captured.setdefault('to', to) or True)
with app.app_context():
from shopdb.api import User
from werkzeug.security import generate_password_hash
active = User(username='partslead', email='lead@site.test',
passwordhash=generate_password_hash('x'), isactive=True)
inactive = User(username='oldtimer', email='gone@site.test',
passwordhash=generate_password_hash('x'),
isactive=False)
db.session.add_all([active, inactive])
db.session.commit()
Setting.set('printedparts_alert_userids',
f'{active.userid},{inactive.userid}',
valuetype='string', category='printedparts')
Setting.set('printedparts_alert_email',
'extra@site.test, lead@site.test',
valuetype='string', category='printedparts')
db.session.commit()
client.post(f'/api/printedparts/items/{item}/restock',
json={'quantity': 10, 'badge': directory_employee},
headers=auth_headers)
take = client.post('/api/printedparts/kiosk/take',
json={'itemcode': '3DP-9001',
'badge': directory_employee, 'quantity': 6}, headers={**auth_headers, **KIOSK_HEADERS})
assert take.status_code == 200 # 4 on hand: crossed threshold 5
assert captured['to'] == ['lead@site.test', 'extra@site.test']
def test_retire_hides_and_restore_returns(client, auth_headers, item):
"""Retire drops the item from the default list and the kiosk; restore
brings it back with history intact."""
assert client.delete(f'/api/printedparts/items/{item}',
headers=auth_headers).status_code == 200
listed = client.get('/api/printedparts/items',
headers=auth_headers).get_json()['data']
assert all(row['printeditemid'] != item for row in listed)
kiosk = client.get('/api/printedparts/kiosk/item/3DP-9001', headers={**auth_headers, **KIOSK_HEADERS})
assert kiosk.status_code == 404
including = client.get('/api/printedparts/items?active=false',
headers=auth_headers)
assert any(row['printeditemid'] == item
for row in including.get_json()['data'])
assert client.post(f'/api/printedparts/items/{item}/restore',
headers=auth_headers).status_code == 200
assert client.get('/api/printedparts/kiosk/item/3DP-9001', headers={**auth_headers, **KIOSK_HEADERS}).status_code == 200
def test_file_revisions_append_and_download(client, auth_headers, item, tmp_path):
"""Uploads mint sequential revisions; download returns the original name."""
import io
first = client.post(f'/api/printedparts/items/{item}/files',
data={'file': (io.BytesIO(b'solid part'), 'clip_v1.stl'),
'note': 'initial'},
headers=auth_headers,
content_type='multipart/form-data')
assert first.status_code == 201, first.get_json()
assert first.get_json()['data']['revision'] == 1
second = client.post(f'/api/printedparts/items/{item}/files',
data={'file': (io.BytesIO(b'G1 X0 Y0'), 'clip_v2.gcode')},
headers=auth_headers,
content_type='multipart/form-data')
assert second.get_json()['data']['revision'] == 2
bad = client.post(f'/api/printedparts/items/{item}/files',
data={'file': (io.BytesIO(b'x'), 'malware.exe')},
headers=auth_headers,
content_type='multipart/form-data')
assert bad.status_code == 400
listing = client.get(f'/api/printedparts/items/{item}/files',
headers=auth_headers).get_json()['data']
assert [f['revision'] for f in listing] == [2, 1]
fileid = listing[1]['fileid']
download = client.get(f'/api/printedparts/files/{fileid}/download', headers=auth_headers)
assert download.status_code == 200
assert download.data == b'solid part'
assert 'clip_v1.stl' in download.headers['Content-Disposition']
def test_alert_role_members_receive(client, auth_headers, app, item,
directory_employee, monkeypatch):
"""Every active member of a selected role gets the alert."""
import shopdb.api as contract_surface
captured = {}
monkeypatch.setattr(contract_surface, 'send_email',
lambda to, subject, html, text=None:
captured.setdefault('to', to) or True)
with app.app_context():
from shopdb.api import User, Role
from werkzeug.security import generate_password_hash
role = Role(rolename='partscrew', description='3D parts crew')
member = User(username='crewone', email='crewone@site.test',
passwordhash=generate_password_hash('x'), isactive=True)
member.roles.append(role)
db.session.add_all([role, member])
db.session.commit()
Setting.set('printedparts_alert_roleids', str(role.roleid),
valuetype='string', category='printedparts')
db.session.commit()
client.post(f'/api/printedparts/items/{item}/restock',
json={'quantity': 10, 'badge': directory_employee},
headers=auth_headers)
take = client.post('/api/printedparts/kiosk/take',
json={'itemcode': '3DP-9001',
'badge': directory_employee, 'quantity': 6}, headers={**auth_headers, **KIOSK_HEADERS})
assert take.status_code == 200
assert captured['to'] == ['crewone@site.test']
def test_gagelabtag_assigned_searched_and_kiosk_resolved(client, auth_headers):
"""The internal code stays auto-minted; the gage-lab tag is optional,
unique, searchable, and the kiosk resolves it (exact and bare digits)."""
created = client.post('/api/printedparts/items',
json={'itemname': 'Gage block holder',
'gagelabtag': 'wjrp0117'},
headers=auth_headers)
assert created.status_code == 201
data = created.get_json()['data']
assert data['gagelabtag'] == 'WJRP0117'
assert data['itemcode'].startswith('3DP') # internal code untouched
duplicate = client.post('/api/printedparts/items',
json={'itemname': 'Other',
'gagelabtag': 'WJRP0117'},
headers=auth_headers)
assert duplicate.status_code == 409
searched = client.get('/api/printedparts/items?search=WJRP0117',
headers=auth_headers).get_json()['data']
assert len(searched) == 1
by_tag = client.get('/api/printedparts/kiosk/item/WJRP0117', headers={**auth_headers, **KIOSK_HEADERS})
assert by_tag.status_code == 200
by_digits = client.get('/api/printedparts/kiosk/item/117', headers={**auth_headers, **KIOSK_HEADERS})
assert by_digits.status_code == 200
assert by_digits.get_json()['data']['gagelabtag'] == 'WJRP0117'
def test_kiosk_take_records_revision_from_qr(client, app, db, directory_employee, auth_headers):
"""A label QR payload 'TAG|rev' resolves the item by TAG and records the
revision on the take (traceability of which print-file rev was consumed)."""
with app.app_context():
row = PrintedItem(itemcode='3DP-7000', gagelabtag='WJRP7000',
itemname='Rev part', quantityonhand=10, lowstockthreshold=2)
db.session.add(row)
db.session.commit()
iid = row.printeditemid
take = client.post('/api/printedparts/kiosk/take', json={
'itemcode': 'WJRP7000|3', 'badge': directory_employee, 'quantity': 1}, headers={**auth_headers, **KIOSK_HEADERS})
assert take.status_code == 200, take.get_json()
with app.app_context():
txn = PrintedItemTransaction.query.filter_by(
printeditemid=iid, transactiontype='take').first()
assert txn is not None
assert txn.revision == 3
# Manual entry (no |rev) records a null revision, still resolves.
take2 = client.post('/api/printedparts/kiosk/take', json={
'itemcode': 'WJRP7000', 'badge': directory_employee, 'quantity': 1}, headers={**auth_headers, **KIOSK_HEADERS})
assert take2.status_code == 200
with app.app_context():
last = (PrintedItemTransaction.query
.filter_by(printeditemid=iid, transactiontype='take')
.order_by(PrintedItemTransaction.transactionid.desc()).first())
assert last.revision is None
def test_lowstock_routes_to_support_team_webhook(client, app, db, directory_employee, auth_headers):
"""A low-stock crossing posts to the selected support team's webhook URL."""
from unittest.mock import patch
from shopdb.core.models import SupportTeam, Setting
with app.app_context():
team = SupportTeam(teamname='3D Print Team',
webhookurl='https://teams.example/3dprint')
db.session.add(team)
db.session.commit()
Setting.set('printedparts_alert_supportteamid', str(team.supportteamid))
row = PrintedItem(itemcode='3DP-7100', gagelabtag='WJRP7100',
itemname='Widget', quantityonhand=6, lowstockthreshold=5)
db.session.add(row)
db.session.commit()
with patch('shopdb.api.send_webhook') as mock_send:
# take 2 -> 6 crosses to 4 (<= threshold 5) -> alert fires
resp = client.post('/api/printedparts/kiosk/take', json={
'itemcode': 'WJRP7100', 'badge': directory_employee, 'quantity': 2}, headers={**auth_headers, **KIOSK_HEADERS})
assert resp.status_code == 200, resp.get_json()
assert mock_send.called
assert mock_send.call_args.kwargs['url'] == 'https://teams.example/3dprint'
# --- the kiosk is off until a site opts in -----------------------------------
#
# These are the cases the Darkwing scan turned into an exploit path. Every one
# of them returned 200 and wrote to the ledger before the gate existed.
def _clear_kiosk(app, db):
with app.app_context():
Setting.set('printedparts_kiosk_enabled', 'false')
Setting.set('printedparts_kiosk_token', '')
db.session.commit()
def test_kiosk_is_shut_when_the_site_has_not_enabled_it(client, app, db, item):
_clear_kiosk(app, db)
assert client.get('/api/printedparts/kiosk/item/3DP-9001').status_code == 404
assert client.post('/api/printedparts/kiosk/take',
json={'itemcode': '3DP-9001', 'badge': '1',
'quantity': 1}).status_code == 404
def test_kiosk_is_shut_without_the_device_token(client, item):
"""Enabled is not enough: an anonymous caller has no token."""
assert client.get('/api/printedparts/kiosk/item/3DP-9001').status_code == 404
assert client.post('/api/printedparts/kiosk/take',
json={'itemcode': '3DP-9001', 'badge': '1',
'quantity': 1}).status_code == 404
def test_kiosk_is_shut_with_the_wrong_device_token(client, item):
wrong = {'X-Kiosk-Token': KIOSK_TOKEN + 'x'}
assert client.get('/api/printedparts/kiosk/item/3DP-9001',
headers=wrong).status_code == 404
assert client.post('/api/printedparts/kiosk/take', headers=wrong,
json={'itemcode': '3DP-9001', 'badge': '1',
'quantity': 1}).status_code == 404
def test_a_single_take_cannot_drain_a_bin(client, app, item, auth_headers,
directory_employee):
"""quantity was bounded only by what was on hand, so one request emptied
the bin. It is capped per transaction now."""
from plugins.printedparts.api.routes import KIOSK_MAX_TAKE_QUANTITY
client.post(f'/api/printedparts/items/{item}/restock',
json={'quantity': KIOSK_MAX_TAKE_QUANTITY + 10,
'badge': directory_employee}, headers=auth_headers)
over = client.post('/api/printedparts/kiosk/take', headers=KIOSK_HEADERS,
json={'itemcode': '3DP-9001',
'badge': directory_employee,
'quantity': KIOSK_MAX_TAKE_QUANTITY + 1})
assert over.status_code == 400
assert str(KIOSK_MAX_TAKE_QUANTITY) in over.get_json()['data']['error']['message']
def test_true_is_not_a_quantity(client, item, directory_employee):
"""isinstance(True, int) is True in Python, so a JSON true used to sail
through the type check and take one."""
resp = client.post('/api/printedparts/kiosk/take', headers=KIOSK_HEADERS,
json={'itemcode': '3DP-9001',
'badge': directory_employee, 'quantity': True})
assert resp.status_code == 400
def test_the_badge_route_is_not_an_enumeration_oracle(client, item, auth_headers,
directory_employee):
"""An unknown badge and a malformed one must be indistinguishable, or the
route tells an anonymous caller which numbers are real employees.
Stock first: the on-hand check runs BEFORE the badge is resolved, so an
empty bin would answer 400 for both and the test would pass without ever
reaching the code it is about."""
client.post(f'/api/printedparts/items/{item}/restock',
json={'quantity': 5, 'badge': directory_employee},
headers=auth_headers)
unknown = client.post('/api/printedparts/kiosk/take', headers=KIOSK_HEADERS,
json={'itemcode': '3DP-9001', 'badge': '999999999',
'quantity': 1})
malformed = client.post('/api/printedparts/kiosk/take', headers=KIOSK_HEADERS,
json={'itemcode': '3DP-9001', 'badge': 'not-a-badge',
'quantity': 1})
assert unknown.status_code == malformed.status_code == 422
assert (unknown.get_json()['data']['error']['message']
== malformed.get_json()['data']['error']['message'])
# --- the race ---------------------------------------------------------------
#
# The take read quantityonhand, compared it in Python, and then wrote. Five
# concurrent takes of 5 from a bin holding 5 all passed that comparison and all
# wrote their own 5 - 5 = 0, so quantityonhand ended up permanently out of step
# with the ledger that had recorded every one of them. The comparison now lives
# in the UPDATE's WHERE clause, where it is atomic.
def test_a_take_that_passed_its_precheck_is_still_refused_by_the_row(
client, auth_headers, app, item, directory_employee, kiosk,
monkeypatch):
"""Simulate the race window: the request holds an item that says 5 on hand
while the row has already been emptied by somebody else."""
from plugins.printedparts.api import routes
from plugins.printedparts.models import PrintedItem
stocked = client.post(f'/api/printedparts/items/{item}/restock',
json={'quantity': 5, 'badge': directory_employee},
headers=auth_headers)
assert stocked.status_code == 200
real_find = routes._kiosk_find_item
def stale_find(itemcode):
"""What the losing request sees: its own read, taken before the winner
committed, still shows a full bin.
Detached first, so setting the attribute changes only this request's
view and never reaches the database - the test is about a stale
reader, not about a second writer.
"""
found = real_find(itemcode)
if found is not None:
db.session.expunge(found)
found.quantityonhand = 5
return found
# Somebody else empties the bin first, through the kiosk, so the ledger
# records it the way a real competing take would.
winner = client.post('/api/printedparts/kiosk/take',
json={'itemcode': '3DP-9001', 'quantity': 5,
'badge': directory_employee},
headers=KIOSK_HEADERS)
assert winner.status_code == 200, winner.get_json()
monkeypatch.setattr(routes, '_kiosk_find_item', stale_find)
taken = client.post('/api/printedparts/kiosk/take',
json={'itemcode': '3DP-9001', 'quantity': 5,
'badge': directory_employee},
headers=KIOSK_HEADERS)
assert taken.status_code == 400, taken.get_json()
assert 'on hand' in taken.get_json()['data']['error']['message']
with app.app_context():
after = db.session.query(PrintedItem).filter_by(
printeditemid=item).first()
assert after.quantityonhand == 0, 'stock must not go negative'
ledger = sum(t.quantitychange for t in after.transactions)
assert ledger == after.quantityonhand, \
'quantityonhand and the ledger must agree'
# --- the number on the bin is the number you type ---------------------------
#
# Keypad entry matched the digits ANYWHERE in the code, and the shipped prefix
# contains one: '3DP0004' collapsed to 30004, so an operator typing the 4
# printed on the label was told no part matches that barcode, and had to work
# out that the leading 3 belonged to the prefix.
def test_the_keypad_matches_the_number_printed_on_the_label(client, db,
auth_headers,
kiosk,
directory_employee):
from plugins.printedparts.models import PrintedItem
created = client.post('/api/printedparts/items', headers=auth_headers,
json={'itemname': 'Keypad clip'})
assert created.status_code == 201, created.get_json()
itemcode = created.get_json()['data']['itemcode']
assert itemcode.startswith('3DP'), itemcode
number = str(int(itemcode[3:]))
found = client.get('/api/printedparts/kiosk/item/%s' % number,
headers=KIOSK_HEADERS)
assert found.status_code == 200, (number, found.get_json())
assert found.get_json()['data']['itemcode'] == itemcode
def test_the_full_code_still_works(client, db, auth_headers, kiosk):
created = client.post('/api/printedparts/items', headers=auth_headers,
json={'itemname': 'Full code clip'})
itemcode = created.get_json()['data']['itemcode']
found = client.get('/api/printedparts/kiosk/item/%s' % itemcode,
headers=KIOSK_HEADERS)
assert found.status_code == 200, found.get_json()
def test_an_ambiguous_number_is_still_refused(client, db, auth_headers, kiosk):
"""Two items whose trailing numbers agree must not resolve to a guess -
taking from the wrong bin is worse than being told to scan the label."""
from plugins.printedparts.models import PrintedItem
first = client.post('/api/printedparts/items', headers=auth_headers,
json={'itemname': 'Ambiguous one'}).get_json()['data']
item = db.session.get(PrintedItem, first['printeditemid'])
item.gagelabtag = 'WJRP%04d' % 7777
db.session.commit()
second = client.post('/api/printedparts/items', headers=auth_headers,
json={'itemname': 'Ambiguous two'}).get_json()['data']
other = db.session.get(PrintedItem, second['printeditemid'])
other.gagelabtag = 'GAGE%04d' % 7777
db.session.commit()
refused = client.get('/api/printedparts/kiosk/item/7777',
headers=KIOSK_HEADERS)
assert refused.status_code == 404, refused.get_json()

View File

@@ -1,30 +0,0 @@
"""Printedparts plugin smoke tests.
Asserts the plugin loads cleanly and satisfies the framework contract.
Replace and extend with domain tests as you build the plugin out.
"""
from plugins.printedparts.plugin import PrintedpartsPlugin
def test_printedparts_plugin_meta_is_valid():
"""PrintedpartsPlugin.meta returns a PluginMeta with the expected name."""
plugin = PrintedpartsPlugin()
assert plugin.meta.name == 'printedparts'
assert plugin.meta.api_prefix == '/api/printedparts'
def test_printedparts_plugin_get_blueprint_returns_blueprint():
"""get_blueprint returns a Flask Blueprint, not None."""
from flask import Blueprint
plugin = PrintedpartsPlugin()
assert isinstance(plugin.get_blueprint(), Blueprint)
def test_printedparts_plugin_get_models_returns_a_model():
"""get_models returns a list with at least one SQLAlchemy model."""
plugin = PrintedpartsPlugin()
models = plugin.get_models()
assert len(models) >= 1
for model in models:
assert hasattr(model, '__tablename__')

View File

@@ -72,18 +72,26 @@ def test_single_employee_recognition_stays_single_card(client, db):
assert current[0]['employeesso'] == '111'
def test_shopfloor_names_resolve_live_when_not_stored(client, app, db):
def test_shopfloor_names_resolve_live_when_not_stored(client, app, db, monkeypatch):
"""A notification imported without employeename shows the directory name,
not the bare SSO - single and split-per-employee paths both."""
from plugins.employees.models import DirectoryEmployee
not the bare SSO.
The directory is the site's external HR database, which no test environment
has, so the core resolver is stubbed. What is under test is that the feed
ASKS - a card that fell back to the SSO whenever employeename was empty was
the regression, and it is invisible if the feed never calls out at all.
"""
from plugins.notifications.models import Notification, NotificationType
from shopdb.core.models import Setting
import shopdb.api as contract
# Patched on shopdb.api, not on shopdb.core.api.employees: the contract
# surface is where a plugin imports it from, and patching the defining
# module would leave the name the plugin actually binds untouched.
monkeypatch.setattr(
contract, 'resolve_employee_display_name',
lambda sso: 'Recert Person' if str(sso) == '502000777' else None)
with app.app_context():
Setting.set('employee_directory_mode', 'selfhosted',
valuetype='string', category='employees')
db.session.add(DirectoryEmployee(
sso=502000777, firstname='Recert', lastname='Person'))
ntype = NotificationType(typename='Recertification',
typecolor='recertification',
splitperemployee=True)

View File

@@ -1,99 +0,0 @@
"""Folding an external check-in/out LOG into self-hosted checkout PAIRS.
The external cmmc_usb database records one row per action; the self-hosted
usbcheckouts table records one row per checkout, carrying both times. The
import has to convert between them, and the interesting cases are all the ways
a real log is untidy after years of use: a drive still out, a return nobody
has a matching issue for, two issues in a row.
The rule these tests pin down is that NOTHING is invented and NOTHING vanishes
silently. An unclosed checkout stays unclosed; anything that cannot be paired
is reported. A quieter import that guessed the missing halves would produce a
tidier database and a false audit history, which for removable-media records is
the worst of the available outcomes.
"""
from plugins.usb.cli import _pair, _tri
def _row(action, when, badge='B1'):
return {'action': action, 'timestamp': when, 'badge_number': badge}
def test_a_clean_out_and_in_becomes_one_closed_checkout():
problems = []
pairs = _pair([_row('check-out', 1), _row('check-in', 2)], problems, 'DEV1')
assert len(pairs) == 1
out, back = pairs[0]
assert out['timestamp'] == 1
assert back['timestamp'] == 2
assert problems == []
def test_a_drive_still_out_stays_open_and_is_not_reported_as_a_problem():
# This is the normal state of any drive currently issued to somebody, not
# a data fault, so it must not appear in the problem list.
problems = []
pairs = _pair([_row('check-out', 1)], problems, 'DEV1')
assert len(pairs) == 1
out, back = pairs[0]
assert back is None
assert problems == []
def test_a_return_with_no_matching_issue_is_skipped_and_reported():
problems = []
pairs = _pair([_row('check-in', 5)], problems, 'DEV1')
assert pairs == []
assert len(problems) == 1
assert 'no matching check-out' in problems[0]
assert 'DEV1' in problems[0]
def test_two_issues_in_a_row_leave_the_first_open_rather_than_inventing_a_return():
problems = []
pairs = _pair([_row('check-out', 1, 'ALICE'),
_row('check-out', 2, 'BOB'),
_row('check-in', 3, 'BOB')], problems, 'DEV1')
assert len(pairs) == 2
first, second = pairs
assert first[0]['badge_number'] == 'ALICE'
assert first[1] is None, 'the first checkout must not be given a return time'
assert second[0]['badge_number'] == 'BOB'
assert second[1]['timestamp'] == 3
assert len(problems) == 1
assert 'still out' in problems[0]
def test_an_unrecognised_action_is_skipped_and_reported():
problems = []
pairs = _pair([_row('destroyed', 1)], problems, 'DEV1')
assert pairs == []
assert len(problems) == 1
assert 'unrecognised action' in problems[0]
def test_action_spelling_variants_are_accepted():
# Years of a hand-maintained log produce more than one spelling.
for out_word, in_word in (('check-out', 'check-in'),
('checkout', 'checkin'),
('out', 'in'),
('CHECK_OUT', 'RETURN')):
problems = []
pairs = _pair([_row(out_word, 1), _row(in_word, 2)], problems, 'DEV1')
assert len(pairs) == 1, out_word
assert pairs[0][1] is not None, out_word
assert problems == [], out_word
def test_unrecorded_flags_stay_unknown_rather_than_becoming_false():
# NULL in the source means nobody wrote it down. False would assert that
# the drive was checked and failed, which is a different claim entirely.
assert _tri(None) is None
assert _tri(0) is False
assert _tri(1) is True

View File

@@ -1,439 +0,0 @@
"""The USB kiosk: an UNAUTHENTICATED write, so the guards are the feature.
The kiosk exists so somebody at the drive cabinet - with no ShopDB login - can
take a drive and put it back, with the action attributed to their badge instead
of to a signed-in operator. Everything here is about the bar that opens the door
only as far as it has to: it can move a device between checked-out and
checked-in and nothing else, it refuses a badge it cannot put a name to, and it
does not exist at all until a site turns it on.
Self-hosted mode. The external cmmc_usb path is the same contract against raw
SQL and needs that database to exercise.
"""
import pytest
from shopdb.core.models import Setting
KIOSK_TOKEN = 'usb-kiosk-device-token-for-tests'
# What a provisioned screen sends. Turning the kiosk on is a decision; this is
# the credential.
KIOSK_HEADERS = {'X-Kiosk-Token': KIOSK_TOKEN}
@pytest.fixture
def kiosk(db):
"""Self-hosted USB, kiosk ON, one employee and one available drive."""
from plugins.employees.models import DirectoryEmployee
from plugins.usb.models import USBDevice
db.session.add(Setting(key='usb_directory_mode', value='selfhosted',
valuetype='string', category='site'))
db.session.add(Setting(key='usb_kiosk_enabled', value='true',
valuetype='boolean', category='usb'))
db.session.add(Setting(key='usb_kiosk_token', value=KIOSK_TOKEN,
valuetype='string', category='usb'))
db.session.add(DirectoryEmployee(sso=270015376, firstname='Dana',
lastname='Reyes'))
db.session.add(DirectoryEmployee(sso=270099999, firstname='Sam',
lastname='Okafor'))
db.session.add(USBDevice(serialnumber='USB-1', label='Blue Kingston',
ischeckedout=False))
db.session.commit()
return db
def _setting(db, key, value):
row = Setting.query.filter_by(key=key).first()
if row is None:
db.session.add(Setting(key=key, value=value, valuetype='boolean',
category='usb'))
else:
row.value = value
db.session.commit()
def _out(client, badge='270015376', **extra):
return client.post('/api/usb/kiosk/checkout',
json=dict({'device_id': 'USB-1', 'badge': badge}, **extra), headers=KIOSK_HEADERS)
def _in(client, badge='270015376', **extra):
return client.post('/api/usb/kiosk/checkin',
json=dict({'device_id': 'USB-1', 'badge': badge}, **extra), headers=KIOSK_HEADERS)
# --- the door itself --------------------------------------------------------
def test_the_kiosk_does_not_exist_until_a_site_turns_it_on(client, kiosk):
"""Default off, and 404 rather than 403.
An unauthenticated write is a decision about the door. A site that has not
made that decision should not have these routes answering at all, and a
disabled kiosk should not advertise that there is a door here.
"""
_setting(kiosk, 'usb_kiosk_enabled', 'false')
assert client.get('/api/usb/kiosk/device/USB-1', headers=KIOSK_HEADERS).status_code == 404
assert _out(client).status_code == 404
assert _in(client).status_code == 404
def test_it_ships_off(app):
"""The declared default is what a fresh site gets."""
from plugins.usb.plugin import USBPlugin
defaults = {s['key']: s for s in USBPlugin().get_settings_defaults()}
assert defaults['usb_kiosk_enabled']['value'] == 'false'
def test_no_login_is_required(client, kiosk):
"""The whole point: no Authorization header anywhere in this file."""
response = client.get('/api/usb/kiosk/device/USB-1', headers=KIOSK_HEADERS)
assert response.status_code == 200
assert response.get_json()['data']['device_id'] == 'USB-1'
def test_the_kiosk_cannot_create_edit_or_delete(client, kiosk):
"""It moves a device between two states. It is not an admin surface."""
assert client.post('/api/usb/kiosk/device/USB-1', headers=KIOSK_HEADERS).status_code in (404, 405)
assert client.delete('/api/usb/kiosk/device/USB-1').status_code in (404, 405)
# And the authenticated write routes are still gated.
assert client.post('/api/usb/USB-1/checkout',
json={'badge': '270015376'}).status_code == 401
# --- badges -----------------------------------------------------------------
def test_a_badge_it_cannot_name_is_refused(client, kiosk):
"""A checkout nobody can trace to a person defeats the tracking.
The signed-in route creates the user row for an unknown badge; the kiosk
must not, because there is no operator standing there to vouch for it.
"""
response = _out(client, badge='999999999')
assert response.status_code == 422
assert 'not recognized' in response.get_json()['data']['error']['message']
from plugins.usb.models import USBDevice
assert USBDevice.query.filter_by(serialnumber='USB-1').first().ischeckedout is False
def test_a_missing_badge_is_refused(client, kiosk):
assert client.post('/api/usb/kiosk/checkout',
json={'device_id': 'USB-1'}, headers=KIOSK_HEADERS).status_code == 400
# --- taking one -------------------------------------------------------------
def test_taking_a_drive_records_who_took_it(client, kiosk):
from plugins.usb.models import USBDevice
response = _out(client)
assert response.status_code == 200, response.get_json()
device = USBDevice.query.filter_by(serialnumber='USB-1').first()
assert device.ischeckedout is True
assert device.currentuserid == '270015376'
assert device.currentusername == 'Dana Reyes'
def test_a_drive_already_out_cannot_be_taken_again(client, kiosk):
_out(client)
response = _out(client, badge='270099999')
assert response.status_code == 409
assert 'Dana Reyes' in response.get_json()['data']['error']['message']
def test_an_unknown_code_is_a_404(client, kiosk):
response = client.post('/api/usb/kiosk/checkout',
json={'device_id': 'NOPE', 'badge': '270015376'}, headers=KIOSK_HEADERS)
assert response.status_code == 404
# --- putting one back -------------------------------------------------------
def test_the_holder_returns_their_own_drive(client, kiosk):
from plugins.usb.models import USBDevice
_out(client)
assert _in(client).status_code == 200
device = USBDevice.query.filter_by(serialnumber='USB-1').first()
assert device.ischeckedout is False
assert device.currentuserid is None
def test_returning_somebody_elses_drive_asks_first(client, kiosk):
"""Not a silent success: the record is about to say they handed it back."""
from plugins.usb.models import USBDevice
_out(client)
response = _in(client, badge='270099999')
assert response.status_code == 409
details = response.get_json()['data']['error']['details']
assert details['needsconfirm'] is True
assert details['holdername'] == 'Dana Reyes'
assert USBDevice.query.filter_by(serialnumber='USB-1').first().ischeckedout is True
def test_confirming_returns_it_for_them(client, kiosk):
"""A drive left on a bench by somebody on leave still gets put back.
Refusing outright just moves the problem to a person with a login.
"""
from plugins.usb.models import USBDevice
_out(client)
response = _in(client, badge='270099999', forsomeoneelse=True)
assert response.status_code == 200, response.get_json()
assert USBDevice.query.filter_by(serialnumber='USB-1').first().ischeckedout is False
def test_a_site_can_refuse_returns_for_others_outright(client, kiosk):
_setting(kiosk, 'usb_kiosk_allow_return_for_others', 'false')
_out(client)
response = _in(client, badge='270099999', forsomeoneelse=True)
assert response.status_code == 403
def test_a_drive_that_is_not_out_cannot_be_returned(client, kiosk):
assert _in(client).status_code == 400
# --- what the screen is allowed to know -------------------------------------
def test_the_lookup_names_the_holder_but_not_their_badge(client, kiosk):
"""The confirm step has to show whose it is; it does not need their SSO."""
_out(client)
data = client.get('/api/usb/kiosk/device/USB-1', headers=KIOSK_HEADERS).get_json()['data']
assert data['holdername'] == 'Dana Reyes'
assert '270015376' not in str(data)
# --- the picker -------------------------------------------------------------
# The drives carry no barcode, so the kiosk cannot ask somebody to present one -
# it has to offer a LIST. That is a real widening of what a logged-out screen
# exposes, so the list is not anonymous: it costs a badge that resolves to a
# real person, the same bar taking a drive already had to clear.
def _session(client, badge='270015376'):
return client.post('/api/usb/kiosk/session', json={'badge': badge}, headers=KIOSK_HEADERS)
def test_the_device_list_costs_a_valid_badge(client, kiosk):
assert client.post('/api/usb/kiosk/session', json={}, headers=KIOSK_HEADERS).status_code == 400
assert _session(client, badge='999999999').status_code == 422
def test_the_list_is_not_reachable_when_the_kiosk_is_off(client, kiosk):
_setting(kiosk, 'usb_kiosk_enabled', 'false')
assert _session(client).status_code == 404
assert client.post('/api/usb/kiosk/others',
json={'badge': '270015376'}, headers=KIOSK_HEADERS).status_code == 404
def test_the_session_separates_yours_from_available(client, kiosk):
from plugins.usb.models import USBDevice
kiosk.session.add(USBDevice(serialnumber='USB-2', label='Second',
ischeckedout=False))
kiosk.session.commit()
_out(client) # 270015376 takes USB-1
data = _session(client).get_json()['data']
assert data['name'] == 'Dana Reyes'
assert [d['device_id'] for d in data['yours']] == ['USB-1']
assert [d['device_id'] for d in data['available']] == ['USB-2']
assert data['otherscount'] == 0
def test_somebody_elses_drive_is_a_count_not_a_name(client, kiosk):
"""A passer-by should not read the cabinet's holders off a wall screen."""
_out(client) # Dana takes USB-1
data = _session(client, badge='270099999').get_json()['data']
assert data['yours'] == []
assert data['otherscount'] == 1
assert 'Dana' not in str(data)
def test_names_appear_only_on_the_deliberate_second_step(client, kiosk):
_out(client)
rows = client.post('/api/usb/kiosk/others',
json={'badge': '270099999'}, headers=KIOSK_HEADERS).get_json()['data']
assert [r['device_id'] for r in rows] == ['USB-1']
assert rows[0]['holdername'] == 'Dana Reyes'
def test_the_others_list_is_gone_when_returns_for_others_are_refused(client, kiosk):
"""No point showing a list of drives the kiosk will not let anyone return."""
_setting(kiosk, 'usb_kiosk_allow_return_for_others', 'false')
_out(client)
assert client.post('/api/usb/kiosk/others',
json={'badge': '270099999'}, headers=KIOSK_HEADERS).status_code == 404
def test_a_retired_drive_is_not_offered(client, kiosk):
from plugins.usb.models import USBDevice
device = USBDevice.query.filter_by(serialnumber='USB-1').first()
device.isactive = False
kiosk.session.commit()
data = _session(client).get_json()['data']
assert data['available'] == []
# --- the flag is a decision, the token is the credential ---------------------
#
# usb_kiosk_enabled alone used to be the whole gate. With the kiosk on, anyone
# who could reach the app could move a drive between checked-out and checked-in
# and have it recorded against a badge they guessed.
def test_kiosk_is_shut_without_the_device_token(client, kiosk):
assert client.get('/api/usb/kiosk/device/USB-1').status_code == 404
assert client.post('/api/usb/kiosk/checkout',
json={'device_id': 'USB-1',
'badge': '270015376'}).status_code == 404
def test_kiosk_is_shut_with_the_wrong_device_token(client, kiosk):
wrong = {'X-Kiosk-Token': KIOSK_TOKEN + 'x'}
assert client.get('/api/usb/kiosk/device/USB-1',
headers=wrong).status_code == 404
assert client.post('/api/usb/kiosk/checkout', headers=wrong,
json={'device_id': 'USB-1',
'badge': '270015376'}).status_code == 404
def test_the_token_alone_is_not_enough_when_the_kiosk_is_off(client, kiosk):
"""Both halves are required: a provisioned screen at a site that has since
switched the kiosk off gets nothing."""
_setting(kiosk, 'usb_kiosk_enabled', 'false')
assert client.get('/api/usb/kiosk/device/USB-1',
headers=KIOSK_HEADERS).status_code == 404
# --- the race, and who returned it ------------------------------------------
#
# The kiosk decided "is this drive available?" in Python and then wrote. Two
# scans of the same drive that interleaved both read "available", so both
# inserted a custody row: one drive recorded out to two people, and the
# documented 409 never fired. The fix puts ischeckedout in the WHERE clause, so
# the database settles it at write time.
def test_a_checkout_that_passed_its_availability_check_is_still_refused(
client, kiosk, monkeypatch):
"""Simulate the race window: the request holds a device object that says
available while the row has already been claimed by somebody else."""
from plugins.usb.api import selfhosted
from plugins.usb.models import USBDevice
first = _out(client, badge='270015376')
assert first.status_code == 200
# What the losing request sees: its own read, taken before the winner
# committed, still says the drive is on the shelf.
stale = USBDevice.query.filter_by(serialnumber='USB-1').first()
monkeypatch.setattr(
selfhosted, '_find',
lambda device_id: _stale_copy(stale))
second = _out(client, badge='270099999')
assert second.status_code == 409, second.get_json()
open_rows = _open_checkouts(USBDevice)
assert len(open_rows) == 1, 'one drive, one open custody row'
assert open_rows[0].sso == '270015376'
def _stale_copy(device):
"""A detached stand-in that still believes the drive is available."""
class Stale:
usbdeviceid = device.usbdeviceid
serialnumber = device.serialnumber
isactive = True
ischeckedout = False
currentuserid = None
currentusername = None
return Stale()
def _open_checkouts(_):
from plugins.usb.models import USBCheckout
return USBCheckout.query.filter_by(checkintime=None).all()
def test_a_return_records_who_handed_it_back_not_only_the_holder(client, kiosk):
"""Returning for a colleague is a supported flow, so the check-in has to
name the person who actually walked it back."""
from plugins.usb.models import USBCheckout
assert _out(client, badge='270015376').status_code == 200
returned = _in(client, badge='270099999', forsomeoneelse=True)
assert returned.status_code == 200, returned.get_json()
row = USBCheckout.query.order_by(USBCheckout.checkoutid.desc()).first()
assert row.sso == '270015376', 'the holder still owns the checkout'
assert row.checkinsso == '270099999', 'the returner is recorded'
assert row.checkinname and 'Sam' in row.checkinname
def test_a_check_in_closes_every_open_row_for_that_device(client, kiosk, db):
"""Defence in depth for history written before the guard existed: a device
can only be out once, so a second open row is corruption, and closing only
the newest left the older ones open forever."""
from plugins.usb.models import USBCheckout, USBDevice
assert _out(client, badge='270015376').status_code == 200
device = USBDevice.query.filter_by(serialnumber='USB-1').first()
db.session.add(USBCheckout(usbdeviceid=device.usbdeviceid, sso='270099999',
checkoutname='Sam Okafor'))
db.session.commit()
assert len(_open_checkouts(USBDevice)) == 2
assert _in(client, badge='270015376').status_code == 200
assert _open_checkouts(USBDevice) == []
# --- one badge vocabulary for the whole site --------------------------------
#
# The parts kiosk understood both shapes a scanner emits; this one took the
# string raw and compared it to an SSO. So an operator whose keyboard-wedge
# scanner emits the PayNo wrapper could take a 3D-printed part and could not
# check out a drive, with the same badge, at two cabinets a few metres apart.
#
# The same change closed a subtler hole: str.isdigit() is true for Arabic-Indic
# digits, so a badge written in them resolved through int() to a REAL employee
# and was then stored as an SSO string that joins to nothing.
def test_the_payno_wrapper_works_at_this_kiosk_too(client, kiosk):
from plugins.usb.models import USBCheckout
response = _out(client, badge='0270015376BZ')
assert response.status_code == 200, response.get_json()
row = USBCheckout.query.order_by(USBCheckout.checkoutid.desc()).first()
assert row.sso == '270015376', \
'the custody row records the SSO, not the wrapper it arrived in'
assert 'Dana' in (row.checkoutname or '')
def test_a_badge_in_non_ascii_digits_is_refused(client, kiosk):
"""It looks like a number to str.isdigit() and parses through int(), so it
resolved to a real employee under an SSO nothing else matches."""
# Written as escapes so this file stays ASCII, which the style gate
# enforces: these are Arabic-Indic digits, U+0669 and friends.
arabic_indic = '\u0669\u0667\u0660\u0660\u0660\u0667\u0661'
response = _out(client, badge=arabic_indic)
assert response.status_code == 422, response.get_json()
def test_the_same_person_scanning_either_shape_is_one_holder(client, kiosk):
"""Wrapper out, bare number back: the check-in has to recognise it as the
same person, or the drive can never be returned by its holder."""
assert _out(client, badge='0270015376BZ').status_code == 200
returned = _in(client, badge='270015376')
assert returned.status_code == 200, returned.get_json()

View File

@@ -1,139 +0,0 @@
"""USB check-in / check-out audit report.
CMMC removable-media tracking is an audit obligation, and the question an audit
asks is "who had this drive, when" - which a total cannot answer. So this
reports EVENTS over a window, with the window named rather than expressed as a
day count nobody should have to work out.
"""
from datetime import datetime, timedelta, timezone
import pytest
from shopdb.core.models import Setting
def _now():
return datetime.now(timezone.utc).replace(tzinfo=None)
@pytest.fixture
def usbdata(db):
"""Self-hosted USB with a loan returned today and one from long ago."""
from plugins.usb.models import USBDevice, USBCheckout
db.session.add(Setting(key='usb_directory_mode', value='selfhosted',
valuetype='string', category='site'))
recent = USBDevice(serialnumber='USB-1', label='Blue Kingston')
old = USBDevice(serialnumber='USB-2', label='Red SanDisk')
db.session.add_all([recent, old])
db.session.flush()
# Taken and returned today: two events.
db.session.add(USBCheckout(usbdeviceid=recent.usbdeviceid, sso='270015376',
checkoutname='Dana Reyes',
checkouttime=_now() - timedelta(hours=3),
checkintime=_now() - timedelta(hours=1)))
# Taken 200 days ago and never returned: one event, outside most windows.
db.session.add(USBCheckout(usbdeviceid=old.usbdeviceid, sso='270099999',
checkoutname='Sam Okafor',
checkouttime=_now() - timedelta(days=200)))
db.session.commit()
return db
def _report(client, headers, **params):
query = '&'.join(f'{k}={v}' for k, v in params.items())
response = client.get(f'/api/usb/reports/audit?{query}', headers=headers)
assert response.status_code == 200, response.get_json()
return response.get_json()['data']
def test_a_loan_produces_both_a_take_and_a_return(client, usbdata, auth_headers):
"""One stored row holds both ends of a loan.
Reporting it once would make a returned drive look identical to one still
out, which is the opposite of what the log is for.
"""
data = _report(client, auth_headers, period='month')
actions = sorted(row['action'] for row in data['rows'])
assert actions == ['check-in', 'check-out']
assert data['summary']['checkouts'] == 1
assert data['summary']['checkins'] == 1
def test_the_window_actually_bounds_the_rows(client, usbdata, auth_headers):
month = _report(client, auth_headers, period='month')
year = _report(client, auth_headers, period='year')
assert {row['device_id'] for row in month['rows']} == {'USB-1'}
assert {row['device_id'] for row in year['rows']} == {'USB-1', 'USB-2'}
def test_all_time_keeps_everything(client, usbdata, auth_headers):
data = _report(client, auth_headers, period='all')
assert data['days'] == 0
assert data['summary']['events'] == 3
def test_today_is_a_window_too(client, usbdata, auth_headers):
"""A shift handover asks what happened today, not this month."""
data = _report(client, auth_headers, period='day')
assert data['days'] == 1
assert data['summary']['events'] == 2
@pytest.mark.parametrize('period,days', [('day', 1), ('month', 30),
('sixmonths', 182), ('year', 365),
('all', 0)])
def test_every_named_window_maps_to_a_day_count(client, usbdata, auth_headers,
period, days):
assert _report(client, auth_headers, period=period)['days'] == days
def test_an_unknown_period_falls_back_rather_than_failing(client, usbdata,
auth_headers):
"""A bad query string should not 500 a report somebody is mid-audit on."""
assert _report(client, auth_headers, period='fortnight')['days'] == 30
def test_rows_are_newest_first(client, usbdata, auth_headers):
rows = _report(client, auth_headers, period='all')['rows']
stamps = [row['timestamp'] for row in rows]
assert stamps == sorted(stamps, reverse=True)
def test_it_says_who_and_which_drive(client, usbdata, auth_headers):
rows = _report(client, auth_headers, period='month')['rows']
row = rows[0]
assert row['name'] == 'Dana Reyes'
assert row['badge'] == '270015376'
assert row['device_id'] == 'USB-1'
assert row['device_desc'] == 'Blue Kingston'
def test_it_exports_as_csv(client, usbdata, auth_headers):
response = client.get('/api/usb/reports/audit?period=all&format=csv',
headers=auth_headers)
assert response.status_code == 200
assert response.mimetype == 'text/csv'
body = response.get_data(as_text=True)
assert body.splitlines()[0].startswith('timestamp,action,device_id')
assert 'Dana Reyes' in body
def test_reading_the_audit_needs_permission(client, usbdata, member_headers):
"""A role-less member cannot read who had which drive."""
response = client.get('/api/usb/reports/audit?period=all',
headers=member_headers)
assert response.status_code == 403
def test_the_report_is_registered_so_it_appears_in_the_catalogue(client, usbdata,
auth_headers):
from plugins.usb.plugin import USBPlugin
report = USBPlugin().get_reports()[0]
assert report['id'] == 'usb-audit'
# The endpoint is what the reports page fetches; a wrong one renders blank.
assert report['endpoint'] == '/api/usb/reports/audit'