Merge wilm fleet alert scope fixes into wj

Brings in wilm commits:
- Make alertscope actually target fleet alerts
- Fix fleet alert server surface inherited from wj

Key changes merged:
- Added _alert_targets_endpoint() and _endpoint_scope_facts() for proper
  scope filtering (hostname, assettype, computertype, location, businessunit)
- Alertscope now properly filters which endpoints receive fleet alerts
- Added comprehensive test coverage in test_endpoints_fleet_alerts.py
- Preserved wj's operator report email enhancements (FQDN, machine relationship)

Resolved conflicts:
- plugins/endpoints/api/routes.py: merged scope filtering with operator report changes
- agent csproj: bumped to 1.14.2 combining both branches' changes
This commit is contained in:
Proudlock
2026-09-09 18:42:09 -04:00
8 changed files with 607 additions and 15 deletions

View File

@@ -212,9 +212,12 @@
server only on failure. Minor because the claim gains a field and an server only on failure. Minor because the claim gains a field and an
older agent ignoring it keeps today's behaviour. No tray change - tray older agent ignoring it keeps today's behaviour. No tray change - tray
stays 1.4.0. --> stays 1.4.0. -->
<!-- 1.14.1: coordinated release with tray 1.6.6 (fleet alert fixes). <!-- 1.14.1: coordinated release with tray 1.6.7 (merged wj 1.14.1).
Merge wilm 1.14.0 changes with wj tray coordination releases. --> Merge wilm 1.14.0 changes with wj tray coordination releases. -->
<Version>1.14.1</Version> <!-- 1.14.2: merge wilm fleet alert scope fixes and operator report enhancements.
Brings in wilm's alertscope targeting and _alert_targets_endpoint function,
along with wj's operator report email improvements (FQDN, machine relationship). -->
<Version>1.14.2</Version>
<InvariantGlobalization>true</InvariantGlobalization> <InvariantGlobalization>true</InvariantGlobalization>
<RuntimeIdentifier>win-x64</RuntimeIdentifier> <RuntimeIdentifier>win-x64</RuntimeIdentifier>
<SelfContained>true</SelfContained> <SelfContained>true</SelfContained>

View File

@@ -32,7 +32,7 @@ from ..models import (
) )
from ..commands import COMMAND_ALLOWLIST, is_allowed_action, ALLOWED_ACTIONS_FOR, row_actions_for, requires_target, action_targetkind, elevated_permission_for, PLACE_POLICY_HOLD, ACTIVE_POLICY_HOLDS, HOLDABLE_ACTIONS_FOR, RELEASE_POLICY_HOLD, ACTIVE_LOCK_FOR, PLACE_ENDPOINT_LOCK, RELEASE_ENDPOINT_LOCK from ..commands import COMMAND_ALLOWLIST, is_allowed_action, ALLOWED_ACTIONS_FOR, row_actions_for, requires_target, action_targetkind, elevated_permission_for, PLACE_POLICY_HOLD, ACTIVE_POLICY_HOLDS, HOLDABLE_ACTIONS_FOR, RELEASE_POLICY_HOLD, ACTIVE_LOCK_FOR, PLACE_ENDPOINT_LOCK, RELEASE_ENDPOINT_LOCK
from ..eventcatalog import CRITICAL_EVENT_IDS, event_severity, event_title from ..eventcatalog import CRITICAL_EVENT_IDS, event_severity, event_title
from shopdb.utils.mailer import send_alert from shopdb.api import send_alert
from markupsafe import escape from markupsafe import escape
endpoints_bp = Blueprint('endpoints', __name__) endpoints_bp = Blueprint('endpoints', __name__)
@@ -3222,6 +3222,93 @@ def serve_operator_report_image(operatorreportid: int):
_DEFAULT_ALERT_TYPE_COLOR = '#17a2b8' _DEFAULT_ALERT_TYPE_COLOR = '#17a2b8'
# Scope keys an alert may be narrowed by.
# rather than treated as a match-nothing, so a typo cannot silently mute an
# alert - see _alert_targets_endpoint.
_ALERT_SCOPE_KEYS = ('hostname', 'assettype', 'computertype', 'location',
'businessunit')
def _endpoint_scope_facts(hostname):
"""The scope-able attributes of the endpoint asking for its alerts.
Returns a dict of lowercased strings keyed by _ALERT_SCOPE_KEYS, or None if
the hostname resolves to no computer. Values that are unset on the asset are
absent from the dict, which makes them unmatchable rather than empty-string
matchable.
"""
from plugins.computers.models import Computer, ComputerType
from shopdb.api import Location, AssetType
row = (db.session.query(Computer.hostname, AssetType.assettype,
ComputerType.computertype, Location.locationname,
Asset.businessunitid)
.join(Asset, Asset.assetid == Computer.assetid)
.outerjoin(ComputerType,
ComputerType.computertypeid == Computer.computertypeid)
.outerjoin(AssetType, AssetType.assettypeid == Asset.assettypeid)
.outerjoin(Location, Location.locationid == Asset.locationid)
.filter(db.func.lower(Computer.hostname) == hostname.lower())
.first())
if row is None:
return None
hostname_value, assettype, computertype, locationname, businessunitid = row
facts = {'hostname': hostname_value, 'assettype': assettype,
'computertype': computertype, 'location': locationname}
# BusinessUnit is not on the plugin contract surface (shopdb.api), so resolve
# its name by table rather than importing the model and breaking the import
# contract the way the mailer import did.
if businessunitid is not None:
name = db.session.execute(
db.text('SELECT businessunit FROM businessunits '
'WHERE businessunitid = :bid'),
{'bid': businessunitid}).scalar()
facts['businessunit'] = name
return {k: str(v).strip().lower() for k, v in facts.items()
if v is not None and str(v).strip()}
def _alert_targets_endpoint(alertscope, facts):
"""Does an alert's scope include this endpoint?
An absent, empty or unusable scope means the whole fleet - that is the
default and the historical behaviour. A scope with keys narrows the alert:
every recognised key must match, and a key may hold a single value or a list
of values, any of which matches. Matching is case-insensitive because these
are operator-typed names, not identifiers.
Unrecognised keys are IGNORED rather than failing the match. A scope is
authored by hand and a typo like {"hostnam": "BAY-01"} would otherwise mute
the alert everywhere and look like a delivery failure. Ignoring the key
sends the alert wider than intended, which is the safe direction for
something whose purpose is to warn people.
"""
if not isinstance(alertscope, dict) or not alertscope:
return True
for key in _ALERT_SCOPE_KEYS:
if key not in alertscope:
continue
wanted = alertscope[key]
if wanted is None:
continue
if isinstance(wanted, (list, tuple, set)):
candidates = {str(v).strip().lower() for v in wanted if v is not None}
else:
candidates = {str(wanted).strip().lower()}
candidates.discard('')
if not candidates:
continue
if facts.get(key) not in candidates:
return False
return True
@endpoints_bp.route('/alerts', methods=['GET']) @endpoints_bp.route('/alerts', methods=['GET'])
def get_fleet_alerts(): def get_fleet_alerts():
"""Fleet alerts for one endpoint by hostname (collector-token auth). """Fleet alerts for one endpoint by hostname (collector-token auth).
@@ -3229,6 +3316,16 @@ def get_fleet_alerts():
Called by the endpoint agent to fetch active, unacknowledged fleet alerts Called by the endpoint agent to fetch active, unacknowledged fleet alerts
for its hostname. Returns alerts with notification type info (typename and for its hostname. Returns alerts with notification type info (typename and
typecolor from database) formatted for display in the agent tray. typecolor from database) formatted for display in the agent tray.
The list is AUTHORITATIVE for this host: it is every alert that should be
live on the endpoint right now, so the agent retires any pending alert it
holds that is absent here. That is how a server-side close or cancel reaches
a machine already showing the alert - an alert deactivated or expired server
side drops out of this list and the agent takes the toast down, rather than
the operator being stuck acknowledging something already withdrawn.
Acknowledgement is terminal, so an acknowledged alert is filtered out here
permanently and a later server-side close is a no-op for that asset.
""" """
error = _collector_auth_or_error() error = _collector_auth_or_error()
if error is not None: if error is not None:
@@ -3265,8 +3362,16 @@ def get_fleet_alerts():
results = query.all() results = query.all()
# Resolved once per poll. An unknown hostname cannot happen here - the
# asset lookup above already returned - but guard anyway so a missing
# computer row means fleet-wide rather than a crash.
facts = _endpoint_scope_facts(hostname) or {}
alerts = [] alerts = []
for notification, ntype in results: for notification, ntype in results:
if not _alert_targets_endpoint(notification.alertscope, facts):
continue
title = notification.notification[:100] if notification.notification else '' title = notification.notification[:100] if notification.notification else ''
if '\n' in title: if '\n' in title:
title = title.split('\n')[0][:100] or '(no title)' title = title.split('\n')[0][:100] or '(no title)'
@@ -3287,9 +3392,19 @@ def get_fleet_alerts():
def acknowledge_fleet_alert(notification_id: int): def acknowledge_fleet_alert(notification_id: int):
"""Agent acknowledges a fleet alert (collector-token auth). """Agent acknowledges a fleet alert (collector-token auth).
The agent posts the hostname plus assetid when dismissing a fleet alert from The agent posts the hostname plus assetid when the operator dismisses a
the tray. Records acknowledgement so this alert will not reappear in future polls. fleet alert from the tray. Records acknowledgement so this alert will not
Idempotent - repeated acknowledgements succeed. reappear in future polls. Idempotent - repeated acknowledgements succeed.
Acknowledgement is TERMINAL for this asset. Once recorded, the alert is
filtered out of get_fleet_alerts for this machine permanently, and a later
server-side close or cancel of the same notification is a no-op here: there
is nothing left to withdraw. The agent therefore retries a queued
acknowledgement until it lands without risk of a conflicting later state.
Only a real operator action reaches this route. A toast that merely timed
out on screen is NOT an acknowledgement and the agent does not post it - the
alert stays pending so it can be surfaced again.
""" """
error = _collector_auth_or_error() error = _collector_auth_or_error()
if error is not None: if error is not None:
@@ -3327,9 +3442,10 @@ def acknowledge_fleet_alert(notification_id: int):
f'Asset {assetid} not found', f'Asset {assetid} not found',
http_code=404) http_code=404)
# Validate hostname matches assetid to prevent cross-machine acknowledgements # Validate hostname matches assetid to prevent cross-machine acknowledgements.
resolved_asset = _resolve_asset_for_hostname(hostname) # _resolve_asset_for_hostname returns the asset id itself, not an Asset row.
if resolved_asset is None or resolved_asset.assetid != assetid: resolved_assetid = _resolve_asset_for_hostname(hostname)
if resolved_assetid is None or resolved_assetid != assetid:
return error_response(ErrorCodes.VALIDATION_ERROR, return error_response(ErrorCodes.VALIDATION_ERROR,
'hostname does not match provided assetid') 'hostname does not match provided assetid')

View File

@@ -11,7 +11,7 @@ import click
from shopdb.plugins.base import BasePlugin, PluginMeta from shopdb.plugins.base import BasePlugin, PluginMeta
from shopdb.api import db from shopdb.api import db
from .models import Notification, NotificationType from .models import Notification, NotificationType, NotificationAcknowledgement
from .api import notifications_bp from .api import notifications_bp
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -60,7 +60,7 @@ class NotificationsPlugin(BasePlugin):
def get_models(self) -> List[Type]: def get_models(self) -> List[Type]:
"""Return list of SQLAlchemy model classes.""" """Return list of SQLAlchemy model classes."""
return [Notification, NotificationType] return [Notification, NotificationType, NotificationAcknowledgement]
def init_app(self, app: Flask, db_instance) -> None: def init_app(self, app: Flask, db_instance) -> None:
"""Initialize plugin with Flask app.""" """Initialize plugin with Flask app."""

View File

@@ -71,7 +71,7 @@ PLUGIN_TABLE_OWNERS: dict[str, Iterable[str]] = {
'machines': ('machinetypes', 'machines'), 'machines': ('machinetypes', 'machines'),
'measuringtools': ('measuringtooltypes', 'measuringtools'), 'measuringtools': ('measuringtooltypes', 'measuringtools'),
'network': ('networkdevicetypes', 'networkdevices', 'vlans', 'subnets'), 'network': ('networkdevicetypes', 'networkdevices', 'vlans', 'subnets'),
'notifications': ('notificationtypes', 'notifications'), 'notifications': ('notificationtypes', 'notifications', 'notificationacknowledgements'),
'printedparts': ('printeditems', 'printeditemtransactions', 'printedparts': ('printeditems', 'printeditemtransactions',
'printeditemfiles'), 'printeditemfiles'),
'printers': ('printertypes', 'printers', 'modelsupplies', 'printerdrivers', 'printers': ('printertypes', 'printers', 'modelsupplies', 'printerdrivers',

View File

@@ -110,7 +110,18 @@ EXEMPT_ENDPOINTS = {'knowledgebase.track_click', 'users.update_user',
# collector key it already holds. The human-facing read and # collector key it already holds. The human-facing read and
# image-serve routes ARE permission-gated on endpoints.view # image-serve routes ARE permission-gated on endpoints.view
# and ARE swept. # and ARE swept.
'endpoints.upload_operator_report'} 'endpoints.upload_operator_report',
# Agent-facing fleet alert acknowledgement. Authenticated by
# the endpoints collector API key (same shape as the exempt
# collector blueprint), not JWT+role: the operator dismissing
# an alert on a shop-floor bay has no user session, and the
# tray hands the acknowledgement to the service, which posts
# it on the collector key it already holds. The handler binds
# the acknowledgement to the asset the reported hostname
# resolves to, so the key cannot ack another machine's alert.
# AUTHORING a fleet alert is the actual authority and stays
# permission-gated in the notifications plugin.
'endpoints.acknowledge_fleet_alert'}
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)

View File

@@ -120,6 +120,15 @@ PUBLIC_ENDPOINTS = {
# It is a read of already-scoped policy output, not an authority: attaching a # It is a read of already-scoped policy output, not an authority: attaching a
# monitor is gated on the policy editor, audited with the requesting user. # monitor is gated on the policy editor, audited with the requesting user.
'endpoints.resolved_monitors_for_agent', 'endpoints.resolved_monitors_for_agent',
# The endpoint agent fetches the active, unacknowledged fleet alerts for its
# own hostname so the tray can surface them to the operator. Authenticated
# inside the handler by the endpoints collector API key - the same shape as
# claim_next_command - because a bay has no user session. The response is
# scoped to the asset the reported hostname resolves to, so this reads one
# machine's alerts rather than the fleet's. Authoring a fleet alert is the
# actual authority and stays gated in the notifications plugin.
'endpoints.get_fleet_alerts',
} }
# Reads that need a LOGIN but no named permission, because the handler decides # Reads that need a LOGIN but no named permission, because the handler decides

View File

@@ -88,8 +88,9 @@ EXPECTED_HEAD_REVISION['printedparts'] = 'printedparts0004txnrev'
# send needs one; every other tool in that plugin is browser-side. # send needs one; every other tool in that plugin is browser-side.
EXPECTED_HEAD_REVISION['tools'] = 'tools0001securesend' EXPECTED_HEAD_REVISION['tools'] = 'tools0001securesend'
# notifications indexes businessunitid, then adds the per-type grace window and # notifications indexes businessunitid, then adds the per-type grace window and
# the shared board category. # the shared board category, then the fleet-alert columns plus the per-asset
EXPECTED_HEAD_REVISION['notifications'] = 'notifications0005boardorder' # acknowledgement table the endpoint agent writes to.
EXPECTED_HEAD_REVISION['notifications'] = 'notifications0006fleetalerts'
# warranty adds the proof-of-cover document columns on top of its anchor. # warranty adds the proof-of-cover document columns on top of its anchor.
EXPECTED_HEAD_REVISION['warranty'] = 'warranty0002proof' EXPECTED_HEAD_REVISION['warranty'] = 'warranty0002proof'
# endpoints is a NEW post-cutover plugin: its 0001 genuinely creates the # endpoints is a NEW post-cutover plugin: its 0001 genuinely creates the

View File

@@ -0,0 +1,452 @@
"""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_poll_requires_the_collector_key(client, db, app):
"""The alert feed is agent-facing, not anonymous."""
_computer(db, 'BAY-01')
_fleet_alert(db)
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_falls_back_to_the_whole_fleet(client, db, app):
"""Garbled scope JSON must not stop an alert reaching the floor."""
_computer(db, 'BAY-01')
alert = _fleet_alert(db)
alert.alertscope = 'BAY-01'
db.session.commit()
assert _ids(client, app, 'BAY-01') == [alert.notificationid]
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') == []