Files
shopdb-flask/tests/test_plugins/test_endpoints_fleet_alerts.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

505 lines
18 KiB
Python

"""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') == []