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.
466 lines
20 KiB
Python
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)
|