Files
shopdb-flask/tests/test_plugins/test_endpoints_plugin.py
benjamin.baker d32293ccb2 Auto-register staged agent builds as a pushable application
Gate collect_log on a minimum agent version and bridge the agent version
store to the applications table so a staged build can actually be pushed.

update_app resolves an install source from the applications table alone; it
cannot see agentstore. A build could therefore be staged and current while no
application row existed at all, which is what the fleet was in: the agent was
unpushable. Staging is now the single source of truth - it packages the
current build and upserts the row in the same request, so the row cannot
describe a build that is no longer staged.

PARTIAL BUILD ADVISORY
This is steps 1-4 of a longer rollout and is deliberately incomplete:
  - There is no push trigger yet. The registered package is resolvable and
	verifiable, but nothing enqueues an upgrade; the "behind current build"
	indicator is still display-only.
  - The upgrade template script, its script row, and the enqueue route
	(endpoints.agentupdate) are not written.
  - Blocking constraint for the next slice: run_script executes
	powershell.exe -File <script> with NO argument channel, so a delivered
	upgrade script cannot be passed BaseUrl/CollectorKey and must either
	source them itself or run_script must be extended first.
Nothing here touches a live endpoint. Staging only changes what a future
push would contain.

Details:
  - Package members carry a fixed timestamp. zipfile stamps the current mtime
	by default, so identical input produced a different archive digest on
	every call. update_app pins a download by SHA-256, so a drifting hash
	would rewrite installersha256 out from under an endpoint mid-download and
	fail its verification.
  - All three routes that can change 'current' re-register (upload,
	adopt-legacy, rollback). Covering only upload would leave the row pinned
	to the build an operator just rolled away from, and the push would ship
	it anyway - silent drift.
  - Registration cannot fail an upload. The binary is staged and audited by
	the time it runs, so a packaging problem is reported in the response
	payload rather than raised; returning an error would tell the operator a
	successful upload was rejected.
  - The package is written to a temp name and os.replace'd, so an interrupted
	write cannot leave a truncated archive at the path the row advertises.
  - The row is isinstallable but hidden: machine-managed, and it must not
	appear in the software catalogue beside real licensed applications.
  - Added application_package_location() to the shopdb.api contract surface
	(lazy, matching collector_authorized) returning the package directory and
	URL prefix together. agentregistry reached into shopdb.core internals,
	violating the plugin import contract; sourcing both from one place also
	stops the write location and installpath from drifting apart.

collect_log is refused at enqueue below its minimum agent version, with
numeric comparison - string ordering misplaces 1.9.0.0 against 1.16.0. The
refusal is mirrored in the UI so a command slot is not spent on a call that
is guaranteed to fail.

Test isolation: create_app('testing') resolves instance_path to the real repo
instance/ directory, so agent upload tests write into the developer's actual
store. The new tests redirect both the store and package output into tmp_path
and include a guard test that fails if that isolation regresses. NOTE: the
pre-existing test_endpoints_agentupload.py still has this leak and deposits
1.5.0.0 and 1.6.3 into instance/siteagent/ on a full run - not fixed here.

Suite: 2834 passed, 11 skipped. The two failures (test_docs_publishable, and
a symlink scan needing Windows privileges) were confirmed pre-existing by
stashing this work and reproducing them.
2026-09-09 21:40:51 -04:00

466 lines
20 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, minimum_agent_version_for, agent_meets_minimum,
parse_agent_version,
)
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) == 14
for panel in panels:
assert panel['assettypes'] == ['computer']
assert panel['endpoint'].startswith('/api/endpoints/')
assert '{assetid}' in panel['endpoint']
assert panel['render'] in ('keyvalue', 'list', 'table', 'tabs', 'operatorreports')
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_collect_now_survives_a_policy_that_never_mentions_it():
"""Refresh Status must not be narrowed away by an unrelated policy.
A non-empty effective policy skips the permissive fallback in
ALLOWED_ACTIONS_FOR, so any action the policy does not name is intersected
out. collect_now only asks an agent to report state it already has, so
Configuration Policy is not the authority on it - without the exemption
every policied endpoint refused Refresh Status with "Action not permitted
by policy", a denial the operator could not act on because the policy was
never about that action.
"""
from plugins.endpoints.commands import POLICY_EXEMPT_ACTIONS
assert 'collect_now' in POLICY_EXEMPT_ACTIONS
# Exemption from POLICY is not exemption from PERMISSION: it stays gated by
# the base endpoints.command grant, which is what keeps guests out.
assert elevated_permission_for('collect_now') is None
def test_collect_log_survives_a_policy_that_never_mentions_it():
"""Log collection must not be narrowed away by an unrelated policy.
Same intersection trap as collect_now, but with a sharper failure: every
existing policy predates this action and so cannot name it, meaning any
endpoint carrying a policy refused log collection outright - including for
an admin, who reads "requires permission" and goes looking at a grant they
already hold. Logs are also what an operator reaches for when an endpoint
is already misbehaving, so a policy gate here withholds the evidence
exactly when it is wanted.
"""
from plugins.endpoints.commands import POLICY_EXEMPT_ACTIONS
assert 'collect_log' in POLICY_EXEMPT_ACTIONS
# Exemption from POLICY is not exemption from PERMISSION. Unlike
# collect_now this one DOES carry an elevated grant, so the assertion is
# that the grant survived the exemption rather than being loosened by it.
assert elevated_permission_for('collect_log') == 'endpoints.updateapp'
def test_collect_log_declares_the_agent_version_that_implements_it():
"""collect_log names its minimum agent version.
An agent older than the action has no case for it and falls through to its
unknown-action default, answering "Unknown or disallowed action" - which
reads as an authorization refusal and sends the operator to check a grant
they already hold. The floor is what lets the server refuse first with the
actionable answer instead.
"""
assert minimum_agent_version_for('collect_log') == '1.16.0'
# Actions that every shipped agent understands must NOT declare a floor,
# or the guard starts refusing endpoints that are perfectly capable.
assert minimum_agent_version_for('collect_now') is None
assert minimum_agent_version_for('system_restart') is None
def test_agent_version_comparison_survives_real_fleet_version_strings():
"""Version comparison is numeric per part, not lexical.
Two traps, both real in this fleet. Agents report four parts ('1.15.0.0')
while the csproj and binary store carry three ('1.16.0'), so the same build
must compare equal across both spellings. And a lexical compare ranks
'1.9.0.0' ABOVE '1.16.0' because '9' > '1' - which would silently let an
old agent past the guard, the exact failure the guard exists to prevent.
"""
assert parse_agent_version('1.16.0') == parse_agent_version('1.16.0.0')
assert agent_meets_minimum('1.16.0', '1.16.0')
assert agent_meets_minimum('1.16.0.0', '1.16.0')
assert agent_meets_minimum('1.17.0.0', '1.16.0')
assert agent_meets_minimum('2.0.0.0', '1.16.0')
assert not agent_meets_minimum('1.15.0.0', '1.16.0')
assert not agent_meets_minimum('1.11.0.0', '1.16.0')
# The lexical trap.
assert not agent_meets_minimum('1.9.0.0', '1.16.0')
def test_an_unreadable_agent_version_fails_the_guard_closed():
"""An unparseable or absent version does not satisfy a minimum.
Eight endpoints in this fleet report '0.0.0-sample', and a never-checked-in
endpoint reports nothing at all. There is no honest ordering between a
placeholder and a real build, so the guard refuses rather than guessing.
Letting it through would only move the failure to the agent, where the
error message is the misleading one.
"""
assert parse_agent_version('0.0.0-sample') is None
assert not agent_meets_minimum('0.0.0-sample', '1.16.0')
assert not agent_meets_minimum(None, '1.16.0')
assert not agent_meets_minimum('', '1.16.0')
# No declared floor means no opinion: an action every agent implements must
# stay available even to an endpoint whose version we cannot read.
assert agent_meets_minimum('0.0.0-sample', None)
assert agent_meets_minimum(None, 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_big_four_actions_each_require_their_own_elevated_permission():
"""The four scope-gated actions map to four INDEPENDENT permissions.
They previously shared endpoints.deploy, which meant granting an operator
application updates also granted arbitrary SYSTEM code execution through
run_script. Keeping these distinct is the whole point of the split, so a
regression that re-bundles them must fail here.
"""
assert elevated_permission_for('system_restart') == 'endpoints.restart'
assert elevated_permission_for('run_script') == 'endpoints.runscript'
assert elevated_permission_for('update_app') == 'endpoints.updateapp'
assert elevated_permission_for('scheduledtask_run') == 'endpoints.corptools'
granted = {
elevated_permission_for(action)
for action in ('system_restart', 'run_script', 'update_app',
'scheduledtask_run')
}
assert len(granted) == 4
def test_endpoints_declares_the_split_delivery_permissions():
"""Each split grant is declared so role seeding can offer it, and the
retired bundle is gone so the role grid stops offering a dead permission."""
plugin = EndpointsPlugin()
permissions = {name for name, *_ in plugin.get_permissions()}
assert 'endpoints.runscript' in permissions
assert 'endpoints.updateapp' in permissions
assert 'endpoints.corptools' in permissions
assert 'endpoints.restart' in permissions
assert 'endpoints.deploy' not 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_hardware_telemetry_keys_are_persisted_not_merely_logged():
"""Disk/bios/memory/processor/osinfo inventory was collected at the agent and
logged as unknown keys for several versions while the shapes were proven
against real endpoint traffic. They have columns now (endpoints migration
0013), so they must be INSIDE the known set - leaving them out would log
every report as carrying unpersisted keys while the ingest quietly stores
them, which is the most misleading combination available."""
from plugins.endpoints.plugin import KNOWN_PAYLOAD_KEYS
for key in ('disks', 'bios', 'memory', 'processor', 'osinfo', 'domain',
'pendingreboot', 'usercontext'):
assert key in KNOWN_PAYLOAD_KEYS
# 'applications' followed this same staging path earlier and is likewise
# persisted, to its own endpointapplications table rather than a state blob.
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')
# --- agent timestamps are UTC on the wire and UTC in the column --------------
#
# _parse_dt used astimezone(tz=None), which converts to the SERVER's local
# zone. An agent reporting 17:26:16Z was stored as 13:26:16 on an EDT server
# and re-served labelled 'Z', while the same row's server-written
# lastreportedat stayed true UTC - one row, two time bases, four hours apart.
# The dashboard's 48-hour stability window compares a real UTC cutoff against
# these values, so events left it four hours early.
#
# It is invisible on a UTC server, which is how it survived, and nothing in the
# Dockerfile, the compose files or the Windows deploy pins TZ.
def _in_zone(zonename, call):
"""Run call() with the process in a named zone, then put it back.
time.tzset() is POSIX-only and absent on Windows, where the process zone
cannot be moved this way at all. Rather than fail three tests permanently on
every Windows checkout - which teaches the developer to ignore a red suite -
the shift is skipped there and the assertions still run in whatever zone the
machine is in. That is weaker locally but not worthless: _parse_dt must
return the same UTC answer in EVERY zone, so the assertion is correct in all
of them, and CI runs on Linux where the real shift happens.
"""
import os
import time
tzset = getattr(time, 'tzset', None)
if tzset is None:
return call()
before = os.environ.get('TZ')
os.environ['TZ'] = zonename
tzset()
try:
return call()
finally:
if before is None:
os.environ.pop('TZ', None)
else:
os.environ['TZ'] = before
tzset()
def test_a_utc_timestamp_survives_a_non_utc_server():
from datetime import datetime
from plugins.endpoints.plugin import _parse_dt
parsed = _in_zone('America/New_York',
lambda: _parse_dt('2026-09-03T17:26:16Z'))
assert parsed == datetime(2026, 9, 3, 17, 26, 16), \
'the wire value is UTC and the column is UTC; no local conversion'
def test_an_offset_timestamp_is_converted_to_utc_not_to_server_local():
from datetime import datetime
from plugins.endpoints.plugin import _parse_dt
parsed = _in_zone('America/New_York',
lambda: _parse_dt('2026-09-03T17:26:16+05:00'))
assert parsed == datetime(2026, 9, 3, 12, 26, 16)
def test_a_naive_timestamp_is_taken_as_utc_already():
"""AGENT-CONTRACT.md mandates UTC on the wire, so a value with no offset is
UTC rather than a guess about the agent's zone."""
from datetime import datetime
from plugins.endpoints.plugin import _parse_dt
parsed = _in_zone('Asia/Tokyo',
lambda: _parse_dt('2026-09-03T17:26:16'))
assert parsed == datetime(2026, 9, 3, 17, 26, 16)