Bring in the natural-key import fix, Metrics->Monitors rename, PC detail tabbing and panel groups, guest search grants, and the 2026-08-30 security fixes. Reconcile endpoint packaging/installer, migrations, contract version, and tests; keep the delivery uninstaller and 6-step installer alongside main's permission-based RBAC and pin-drift protection.
274 lines
12 KiB
Python
274 lines
12 KiB
Python
"""Endpoints plugin smoke + collector-contract tests.
|
|
|
|
Asserts the plugin loads cleanly, satisfies the framework contract, and that
|
|
its ADR-006 collector schema is well formed. Extend with DB-backed ingest tests
|
|
as the plugin grows.
|
|
"""
|
|
|
|
from flask import Blueprint
|
|
|
|
from plugins.endpoints.plugin import EndpointsPlugin, CURATED_EVENT_IDS
|
|
from plugins.endpoints.eventcatalog import EVENT_CATALOG, CRITICAL_EVENT_IDS
|
|
from plugins.endpoints.commands import (
|
|
COMMAND_ALLOWLIST, is_allowed_action, elevated_permission_for,
|
|
requires_target,
|
|
)
|
|
|
|
|
|
def test_endpoints_plugin_meta_is_valid():
|
|
"""meta returns a PluginMeta with the expected name and prefix."""
|
|
plugin = EndpointsPlugin()
|
|
assert plugin.meta.name == 'endpoints'
|
|
assert plugin.meta.api_prefix == '/api/endpoints'
|
|
assert 'computers' in plugin.meta.dependencies
|
|
|
|
|
|
def test_endpoints_plugin_get_blueprint_returns_blueprint():
|
|
"""get_blueprint returns a Flask Blueprint, not None."""
|
|
plugin = EndpointsPlugin()
|
|
assert isinstance(plugin.get_blueprint(), Blueprint)
|
|
|
|
|
|
def test_endpoints_plugin_get_models_returns_models():
|
|
"""get_models returns the two telemetry tables."""
|
|
plugin = EndpointsPlugin()
|
|
models = plugin.get_models()
|
|
assert len(models) >= 2
|
|
for model in models:
|
|
assert hasattr(model, '__tablename__')
|
|
|
|
|
|
def test_endpoints_collector_schema_identity_is_hostname():
|
|
"""ADR-006 schema declares hostname as the identity field."""
|
|
plugin = EndpointsPlugin()
|
|
schema = plugin.get_collector_schema()
|
|
assert schema is not None
|
|
assert schema['identityfield'] == 'hostname'
|
|
assert 'hostname' in schema['fields']['required']
|
|
|
|
|
|
def test_endpoints_curated_event_ids_include_core_security_ids():
|
|
"""The curated catalog covers the agreed logon/stability IDs."""
|
|
for eventid in (4624, 4625, 4634, 4672, 4697, 4698, 41, 4618, 55, 7011,
|
|
10016, 1001):
|
|
assert eventid in CURATED_EVENT_IDS
|
|
|
|
|
|
def test_curated_ids_match_the_event_catalog():
|
|
"""Ingest filter and display catalog cannot drift apart."""
|
|
assert CURATED_EVENT_IDS == frozenset(EVENT_CATALOG)
|
|
|
|
|
|
def test_critical_event_ids_are_a_subset_of_the_catalog():
|
|
"""Dashboard stability card only queries known critical IDs."""
|
|
assert set(CRITICAL_EVENT_IDS)
|
|
assert set(CRITICAL_EVENT_IDS).issubset(set(EVENT_CATALOG))
|
|
|
|
|
|
def test_endpoints_asset_panels_are_computer_scoped():
|
|
"""Every asset panel targets computer assets only, with an endpoint."""
|
|
plugin = EndpointsPlugin()
|
|
panels = plugin.get_asset_panels()
|
|
assert len(panels) == 6
|
|
for panel in panels:
|
|
assert panel['assettypes'] == ['computer']
|
|
assert panel['endpoint'].startswith('/api/endpoints/asset/{assetid}')
|
|
assert panel['render'] in ('keyvalue', 'list', 'table')
|
|
|
|
|
|
def test_services_and_processes_are_split_into_two_filtered_cards():
|
|
"""The single services card is now two cards filtered by row kind, both
|
|
reading the same services snapshot so a future top-20 collector is just
|
|
another filtered panel over the same feed."""
|
|
plugin = EndpointsPlugin()
|
|
panels = {p['id']: p for p in plugin.get_asset_panels()}
|
|
services = panels['endpoints-tracked-services']
|
|
processes = panels['endpoints-tracked-processes']
|
|
assert services['title'] == 'Tracked Services'
|
|
assert processes['title'] == 'Tracked Processes'
|
|
assert services['filter'] == {'key': 'kind', 'equals': 'service'}
|
|
assert processes['filter'] == {'key': 'kind', 'equals': 'process'}
|
|
# Both slice one endpoint feed.
|
|
assert services['endpoint'] == processes['endpoint']
|
|
assert services['endpoint'].endswith('/services')
|
|
# Command wiring carries over to both cards.
|
|
assert services['permission'] == 'endpoints.command'
|
|
assert processes['permission'] == 'endpoints.command'
|
|
|
|
|
|
def test_activity_card_starts_collapsed_and_sits_below_live_state():
|
|
"""The activity feed is history, so it must not push live state down.
|
|
|
|
Renamed from the events card when events and commands merged into one feed.
|
|
It is no longer the LAST panel - installed software sits below it - so the
|
|
assertion is that it ranks below every live-state panel rather than being
|
|
the maximum, which would break again the next time a reference panel is
|
|
added underneath.
|
|
"""
|
|
plugin = EndpointsPlugin()
|
|
panels = plugin.get_asset_panels()
|
|
activity = next(p for p in panels if p['id'] == 'endpoints-activity')
|
|
# NOT collapsed by default, unlike the events card it replaced. The feed
|
|
# surfaces critical events, and a card that hides the one thing worth seeing
|
|
# is the reason the health dot and the visible rows disagreed. Collapsible,
|
|
# so a reader can put it away.
|
|
assert activity.get('collapsible') is True
|
|
assert activity.get('collapsedDefault') is not True
|
|
live = [p['position'] for p in panels
|
|
if p['id'] in ('endpoints-status', 'endpoints-tracked-services',
|
|
'endpoints-tracked-processes', 'endpoints-interfaces')]
|
|
assert activity['position'] > max(live)
|
|
|
|
|
|
def test_endpoints_dashboard_widgets_declared():
|
|
"""Quiet + stability exception cards are declared and hide when empty."""
|
|
plugin = EndpointsPlugin()
|
|
widgets = plugin.get_dashboard_widgets()
|
|
ids = {w['id'] for w in widgets}
|
|
assert {'endpoints-quiet', 'endpoints-stability'} <= ids
|
|
for widget in widgets:
|
|
assert widget['render'] == 'exceptions'
|
|
assert widget['empty'] == 'hide'
|
|
# The fleet-wide cards read the whole roster, so they are gated on
|
|
# endpoints.fleet, not the per-endpoint endpoints.view a guest may hold.
|
|
assert widget['permission'] == 'endpoints.fleet'
|
|
|
|
|
|
def test_endpoints_navigation_item_points_at_fleet_view():
|
|
"""The fleet list view is exposed as a single /endpoints nav item."""
|
|
plugin = EndpointsPlugin()
|
|
items = plugin.get_navigation_items()
|
|
routes = {item['route'] for item in items}
|
|
assert '/endpoints' in routes
|
|
|
|
|
|
def test_system_restart_is_allowlisted_and_policy_selectable():
|
|
"""Restart is a real allowlisted action, so the policy builder (which
|
|
reads COMMAND_ALLOWLIST) surfaces it as a selectable action."""
|
|
assert is_allowed_action('system_restart')
|
|
assert 'system_restart' in COMMAND_ALLOWLIST
|
|
assert COMMAND_ALLOWLIST['system_restart']['label'] == 'Restart computer'
|
|
|
|
|
|
def test_system_restart_requires_the_elevated_restart_permission():
|
|
"""Restart maps to endpoints.restart so the enqueue route can demand a
|
|
heavier grant than the routine endpoints.command toggle. Routine actions
|
|
carry no elevated requirement."""
|
|
assert elevated_permission_for('system_restart') == 'endpoints.restart'
|
|
assert elevated_permission_for('mtconnect_start') is None
|
|
|
|
|
|
def test_endpoints_declares_the_restart_permission():
|
|
"""The dedicated restart permission is declared for role-grid seeding."""
|
|
plugin = EndpointsPlugin()
|
|
permissions = {name for name, *_ in plugin.get_permissions()}
|
|
assert 'endpoints.restart' in permissions
|
|
assert 'endpoints.command' in permissions
|
|
|
|
|
|
def test_collect_now_is_allowlisted_targetless_and_routine():
|
|
"""collect_now forces an off-cadence telemetry report. It is allowlisted so
|
|
the enqueue route accepts it, carries no wire target (fixed action), and
|
|
needs no elevated grant beyond endpoints.command."""
|
|
assert is_allowed_action('collect_now')
|
|
assert 'collect_now' in COMMAND_ALLOWLIST
|
|
assert not requires_target('collect_now')
|
|
assert elevated_permission_for('collect_now') is None
|
|
|
|
|
|
def test_run_script_and_update_app_are_allowlisted_delivery_actions():
|
|
"""The two software-delivery actions are allowlisted with a targetkind so
|
|
the enqueue route accepts them and carries their subject on the wire:
|
|
run_script carries a script name, update_app carries an appversionid."""
|
|
for action in ('run_script', 'update_app'):
|
|
assert is_allowed_action(action)
|
|
assert action in COMMAND_ALLOWLIST
|
|
assert requires_target(action)
|
|
assert COMMAND_ALLOWLIST['run_script']['targetkind'] == 'script'
|
|
assert COMMAND_ALLOWLIST['update_app']['targetkind'] == 'app'
|
|
|
|
|
|
def test_delivery_actions_require_the_elevated_deploy_permission():
|
|
"""run_script and update_app both demand endpoints.deploy ON TOP OF
|
|
endpoints.command, so pushing software is a strictly heavier grant than a
|
|
routine service/process toggle."""
|
|
assert elevated_permission_for('run_script') == 'endpoints.deploy'
|
|
assert elevated_permission_for('update_app') == 'endpoints.deploy'
|
|
|
|
|
|
def test_endpoints_declares_the_deploy_permission():
|
|
"""The dedicated software-delivery permission is declared for role seeding."""
|
|
plugin = EndpointsPlugin()
|
|
permissions = {name for name, *_ in plugin.get_permissions()}
|
|
assert 'endpoints.deploy' in permissions
|
|
|
|
|
|
def test_known_payload_keys_cover_persisted_and_relayed_fields():
|
|
"""The ingest allow-set must list every key the handler consumes today, so
|
|
those keys are never mislogged as 'unpersisted'. Anything the agent sends
|
|
outside this set is logged for observability before columns exist."""
|
|
from plugins.endpoints.plugin import KNOWN_PAYLOAD_KEYS
|
|
|
|
persisted_or_relayed = {
|
|
'hostname', 'adopt', 'loggedinuser', 'serialnumber', 'pctype',
|
|
'machinenumber', 'vendorname', 'modelnumber', 'osname', 'ipaddress',
|
|
'agentversion', 'lastboottime', 'uptimeseconds', 'idleseconds',
|
|
'cpupercent', 'memorypercent', 'services', 'ports', 'interfaces',
|
|
'scheduledtasks', 'events',
|
|
}
|
|
assert persisted_or_relayed <= KNOWN_PAYLOAD_KEYS
|
|
|
|
|
|
def test_new_hardware_telemetry_keys_are_logged_not_silently_known():
|
|
"""Disk/bios/memory inventory is collected at the agent before the server
|
|
persists it. Those keys must be OUTSIDE the known set so ingest logs them
|
|
rather than dropping them silently."""
|
|
from plugins.endpoints.plugin import KNOWN_PAYLOAD_KEYS
|
|
|
|
for key in ('disks', 'bios', 'memory', 'processor', 'osinfo', 'domain'):
|
|
assert key not in KNOWN_PAYLOAD_KEYS
|
|
|
|
# 'applications' USED to be in this list. It is persisted now - that is what
|
|
# the installed-software work added - so it belongs in the known set, and
|
|
# asserting its absence would fail the moment the feature it tests works.
|
|
assert 'applications' in KNOWN_PAYLOAD_KEYS
|
|
|
|
|
|
def test_telemetrymeta_route_is_registered_for_freshness_gating():
|
|
"""The WebUI gates panel refreshes on lastreportedat advancing, so the
|
|
lightweight freshness probe must be exposed on the plugin blueprint."""
|
|
from flask import Flask
|
|
|
|
application = Flask(__name__)
|
|
# A url_prefix is REQUIRED here: the fleet list registers as route(''), which
|
|
# Flask rejects as a bare rule and accepts under a prefix. That is deliberate
|
|
# - CONTRIBUTING says API routes carry no trailing slash - so the test has to
|
|
# mount the blueprint the way the app does.
|
|
application.register_blueprint(EndpointsPlugin().get_blueprint(),
|
|
url_prefix='/api/endpoints')
|
|
rules = {rule.rule for rule in application.url_map.iter_rules()}
|
|
assert any(rule.endswith('/asset/<int:asset_id>/telemetrymeta') for rule in rules)
|
|
|
|
|
|
def test_fleet_list_route_resolves_without_trailing_slash():
|
|
"""The Fleet list route must match the slash-less URL the SPA calls.
|
|
|
|
The list is the only route on the bare blueprint prefix. If it demands a
|
|
trailing slash, Flask 308-redirects /api/endpoints to /api/endpoints/, and
|
|
behind the /shopdb reverse proxy that redirect drops the path prefix and
|
|
the list call fails while the detail routes (which carry a path suffix)
|
|
keep working. strict_slashes=False makes both forms resolve directly.
|
|
"""
|
|
from flask import Flask
|
|
|
|
application = Flask(__name__)
|
|
application.register_blueprint(
|
|
EndpointsPlugin().get_blueprint(), url_prefix='/api/endpoints')
|
|
adapter = application.url_map.bind('localhost')
|
|
|
|
endpoint, _ = adapter.match('/api/endpoints', method='GET')
|
|
assert endpoint.endswith('list_endpoints')
|
|
endpoint_slash, _ = adapter.match('/api/endpoints/', method='GET')
|
|
assert endpoint_slash.endswith('list_endpoints')
|
|
|