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:
@@ -212,9 +212,12 @@
|
||||
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
|
||||
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. -->
|
||||
<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>
|
||||
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||
<SelfContained>true</SelfContained>
|
||||
|
||||
@@ -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 ..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
|
||||
|
||||
endpoints_bp = Blueprint('endpoints', __name__)
|
||||
@@ -3222,6 +3222,93 @@ def serve_operator_report_image(operatorreportid: int):
|
||||
|
||||
_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'])
|
||||
def get_fleet_alerts():
|
||||
"""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
|
||||
for its hostname. Returns alerts with notification type info (typename and
|
||||
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()
|
||||
if error is not None:
|
||||
@@ -3265,8 +3362,16 @@ def get_fleet_alerts():
|
||||
|
||||
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 = []
|
||||
for notification, ntype in results:
|
||||
if not _alert_targets_endpoint(notification.alertscope, facts):
|
||||
continue
|
||||
|
||||
title = notification.notification[:100] if notification.notification else ''
|
||||
if '\n' in 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):
|
||||
"""Agent acknowledges a fleet alert (collector-token auth).
|
||||
|
||||
The agent posts the hostname plus assetid when dismissing a fleet alert from
|
||||
the tray. Records acknowledgement so this alert will not reappear in future polls.
|
||||
Idempotent - repeated acknowledgements succeed.
|
||||
The agent posts the hostname plus assetid when the operator dismisses a
|
||||
fleet alert from the tray. Records acknowledgement so this alert will not
|
||||
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()
|
||||
if error is not None:
|
||||
@@ -3327,9 +3442,10 @@ def acknowledge_fleet_alert(notification_id: int):
|
||||
f'Asset {assetid} not found',
|
||||
http_code=404)
|
||||
|
||||
# Validate hostname matches assetid to prevent cross-machine acknowledgements
|
||||
resolved_asset = _resolve_asset_for_hostname(hostname)
|
||||
if resolved_asset is None or resolved_asset.assetid != assetid:
|
||||
# Validate hostname matches assetid to prevent cross-machine acknowledgements.
|
||||
# _resolve_asset_for_hostname returns the asset id itself, not an Asset row.
|
||||
resolved_assetid = _resolve_asset_for_hostname(hostname)
|
||||
if resolved_assetid is None or resolved_assetid != assetid:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
'hostname does not match provided assetid')
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import click
|
||||
from shopdb.plugins.base import BasePlugin, PluginMeta
|
||||
from shopdb.api import db
|
||||
|
||||
from .models import Notification, NotificationType
|
||||
from .models import Notification, NotificationType, NotificationAcknowledgement
|
||||
from .api import notifications_bp
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -60,7 +60,7 @@ class NotificationsPlugin(BasePlugin):
|
||||
|
||||
def get_models(self) -> List[Type]:
|
||||
"""Return list of SQLAlchemy model classes."""
|
||||
return [Notification, NotificationType]
|
||||
return [Notification, NotificationType, NotificationAcknowledgement]
|
||||
|
||||
def init_app(self, app: Flask, db_instance) -> None:
|
||||
"""Initialize plugin with Flask app."""
|
||||
|
||||
@@ -71,7 +71,7 @@ PLUGIN_TABLE_OWNERS: dict[str, Iterable[str]] = {
|
||||
'machines': ('machinetypes', 'machines'),
|
||||
'measuringtools': ('measuringtooltypes', 'measuringtools'),
|
||||
'network': ('networkdevicetypes', 'networkdevices', 'vlans', 'subnets'),
|
||||
'notifications': ('notificationtypes', 'notifications'),
|
||||
'notifications': ('notificationtypes', 'notifications', 'notificationacknowledgements'),
|
||||
'printedparts': ('printeditems', 'printeditemtransactions',
|
||||
'printeditemfiles'),
|
||||
'printers': ('printertypes', 'printers', 'modelsupplies', 'printerdrivers',
|
||||
|
||||
@@ -110,7 +110,18 @@ EXEMPT_ENDPOINTS = {'knowledgebase.track_click', 'users.update_user',
|
||||
# collector key it already holds. The human-facing read and
|
||||
# image-serve routes ARE permission-gated on endpoints.view
|
||||
# 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)
|
||||
|
||||
@@ -120,6 +120,15 @@ PUBLIC_ENDPOINTS = {
|
||||
# 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.
|
||||
'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
|
||||
|
||||
@@ -88,8 +88,9 @@ EXPECTED_HEAD_REVISION['printedparts'] = 'printedparts0004txnrev'
|
||||
# send needs one; every other tool in that plugin is browser-side.
|
||||
EXPECTED_HEAD_REVISION['tools'] = 'tools0001securesend'
|
||||
# notifications indexes businessunitid, then adds the per-type grace window and
|
||||
# the shared board category.
|
||||
EXPECTED_HEAD_REVISION['notifications'] = 'notifications0005boardorder'
|
||||
# the shared board category, then the fleet-alert columns plus the per-asset
|
||||
# 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.
|
||||
EXPECTED_HEAD_REVISION['warranty'] = 'warranty0002proof'
|
||||
# endpoints is a NEW post-cutover plugin: its 0001 genuinely creates the
|
||||
|
||||
452
tests/test_plugins/test_endpoints_fleet_alerts.py
Normal file
452
tests/test_plugins/test_endpoints_fleet_alerts.py
Normal 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') == []
|
||||
Reference in New Issue
Block a user