Files
shopdb-flask/tests/test_plugins/test_endpoints_script_runs.py
benjamin.baker b49fb60c0c Add log collection, script output capture, and timestamp fixes
Log Collector: a new collect_log action reads a plaintext log file from a
known path and returns its tail for viewing and download. Rides the existing
endpoints.updateapp grant rather than inventing one, since collecting a log is
the same trust level as pushing an application. Collected logs land in their
own table with per-asset retention of 20 instead of overloading the command
audit row, and the agent refuses UNC, traversal, wildcards, directories and
non-text content independently of the server check.

collect_log is POLICY_EXEMPT. Every existing Configuration Policy predates the
action and so cannot name it, and the intersection in ALLOWED_ACTIONS_FOR drops
anything unnamed - so every policied endpoint refused log collection outright,
including for admins, who saw a permission-shaped error for a grant they held.
Exemption from policy is not exemption from permission: the elevated grant
still applies, and a regression test asserts both halves so a future edit
cannot quietly loosen the grant while "fixing" the policy gate.

Script output capture: successful runs can return output, gated per script by
captureoutput. Failures always returned it; success never did, which left a
green badge as the only evidence a run produced.

Timestamps: Activity and Operator Reports rendered date-only because the shared
formatter's 'date' branch drops the clock time. Run Scripts additionally
labels its timestamp, since a bare time meant "completed" on a terminal row but
"queued" on an in-flight one - indistinguishable, and the wrong reading.

Agent 1.16.0. Minor: collect_log is a new action name, so an older agent asked
for it fails closed on the unknown-action default.
2026-09-09 19:23:43 -04:00

201 lines
7.3 KiB
Python

"""The Run Scripts tab's history feed.
A successful script produces a green badge and a one-line message; nothing in
that tells an operator whether the script did any work. These tests pin the
contract the tab relies on: only script runs, newest first, capped, and
carrying the agent's structured detail so the exit code and run context are
visible without opening the database.
"""
import json
from werkzeug.security import generate_password_hash
def _endpoint(db, hostname='scriptbox'):
"""An Asset + Computer pair with a reported state, as the routes expect."""
from shopdb.api import Asset, AssetType
from plugins.computers.models import Computer
from plugins.endpoints.models import EndpointState
assettype = AssetType.query.filter_by(assettype='computer').first()
if assettype is None:
assettype = AssetType(assettype='computer', pluginname='computers',
tablename='computers')
db.session.add(assettype)
db.session.flush()
asset = Asset(assetnumber=hostname, name=hostname,
assettypeid=assettype.assettypeid, isactive=True)
db.session.add(asset)
db.session.flush()
db.session.add(Computer(assetid=asset.assetid, hostname=hostname))
db.session.add(EndpointState(assetid=asset.assetid, hostname=hostname))
db.session.commit()
return asset.assetid
def _viewer(db, client, username='scriptviewer'):
from shopdb.core.models import User, Role, Permission
Permission.seed()
db.session.flush()
role = Role.query.filter_by(rolename='scriptviewerrole').first()
if role is None:
role = Role(rolename='scriptviewerrole')
db.session.add(role)
db.session.flush()
role.permissions = Permission.query.filter(
Permission.name.in_(['endpoints.view'])).all()
user = User(username=username, email=username + '@test.local',
passwordhash=generate_password_hash('testpass'))
user.roles.append(role)
db.session.add(user)
db.session.commit()
response = client.post('/api/auth/login',
json={'username': username, 'password': 'testpass'})
token = response.get_json()['data']['access_token']
return {'Authorization': f'Bearer {token}'}
def _script(db, name='test-open-notepad'):
from shopdb.core.models import Script
script = Script(scriptname=name, runcontext='system')
db.session.add(script)
db.session.commit()
return script.scriptid
def _command(db, assetid, target, createdat, action='run_script',
status='succeeded', resultjson=None, resultmessage=None):
from plugins.endpoints.models import EndpointCommand
command = EndpointCommand(assetid=assetid, action=action, target=target,
status=status, resultmessage=resultmessage,
resultjson=resultjson, createdat=createdat)
db.session.add(command)
db.session.commit()
return command.endpointcommandid
def test_only_script_runs_are_returned(app, client, db):
"""A restart sitting in the same feed is not a script run.
The tab asks a narrower question than the activity panel, so filtering has
to happen server-side; a mixed feed sliced client-side would quietly show
fewer than five script runs whenever other commands interleave.
"""
from datetime import datetime
with app.app_context():
assetid = _endpoint(db)
scriptid = _script(db)
headers = _viewer(db, client)
_command(db, assetid, f'script:{scriptid}', datetime(2026, 1, 1, 10, 0))
_command(db, assetid, 'restart', datetime(2026, 1, 1, 11, 0),
action='restart')
response = client.get(f'/api/endpoints/{assetid}/scriptruns',
headers=headers)
rows = response.get_json()['data']
assert response.status_code == 200
assert [row['action'] for row in rows] == ['run_script']
def test_newest_first_and_capped_at_five(app, client, db):
"""Six runs, five rows, most recent at the top."""
from datetime import datetime
with app.app_context():
assetid = _endpoint(db)
scriptid = _script(db)
headers = _viewer(db, client)
for hour in range(6):
_command(db, assetid, f'script:{scriptid}',
datetime(2026, 1, 1, 10 + hour, 0))
response = client.get(f'/api/endpoints/{assetid}/scriptruns',
headers=headers)
rows = response.get_json()['data']
assert len(rows) == 5
stamps = [row['createdat'] for row in rows]
assert stamps == sorted(stamps, reverse=True)
def test_agent_detail_is_exposed(app, client, db):
"""The exit code and run context are the evidence behind a green badge."""
from datetime import datetime
with app.app_context():
assetid = _endpoint(db)
scriptid = _script(db)
headers = _viewer(db, client)
_command(db, assetid, f'script:{scriptid}', datetime(2026, 1, 1, 10, 0),
resultmessage='Script completed (system context)',
resultjson=json.dumps({'kind': 'script', 'exitcode': 0,
'runcontext': 'system'}))
response = client.get(f'/api/endpoints/{assetid}/scriptruns',
headers=headers)
row = response.get_json()['data'][0]
assert row['resultdetail']['exitcode'] == 0
assert row['resultdetail']['runcontext'] == 'system'
def test_the_script_name_is_resolved(app, client, db):
"""'script:5' is the wire target, not what the operator picked."""
from datetime import datetime
with app.app_context():
assetid = _endpoint(db)
scriptid = _script(db, name='rebuild-shopfloor-user')
headers = _viewer(db, client)
_command(db, assetid, f'script:{scriptid}', datetime(2026, 1, 1, 10, 0))
response = client.get(f'/api/endpoints/{assetid}/scriptruns',
headers=headers)
row = response.get_json()['data'][0]
assert row['scriptname'] == 'rebuild-shopfloor-user'
def test_malformed_detail_does_not_break_the_list(app, client, db):
"""resultjson is agent-supplied text and can arrive truncated.
A diagnostics panel failing to parse must degrade to "no detail", not take
down the history that surrounds it.
"""
from datetime import datetime
with app.app_context():
assetid = _endpoint(db)
scriptid = _script(db)
headers = _viewer(db, client)
_command(db, assetid, f'script:{scriptid}', datetime(2026, 1, 1, 10, 0),
resultjson='{"kind": "script", "exitcode":')
response = client.get(f'/api/endpoints/{assetid}/scriptruns',
headers=headers)
assert response.status_code == 200
assert response.get_json()['data'][0]['resultdetail'] is None
def test_view_permission_is_required(app, client, db):
"""History reveals what has been run on a machine, so it is gated."""
from datetime import datetime
with app.app_context():
assetid = _endpoint(db)
scriptid = _script(db)
_command(db, assetid, f'script:{scriptid}', datetime(2026, 1, 1, 10, 0))
response = client.get(f'/api/endpoints/{assetid}/scriptruns')
assert response.status_code in (401, 403)