Files
shopdb-flask/tests/test_plugins/test_endpoints_health.py
cproudlock 534f179ee6 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.
2026-09-19 11:24:40 -04:00

321 lines
12 KiB
Python

"""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)