Hard delete applications, nest installer packages, detect PE versions

Soft delete left orphaned executables on disk and let a deleted name collide
with a fresh import, so delete is now a hard delete that removes the
associated files. The destructive paths (delete, revert-to-record) require a
typed OK because neither can be undone.

Installers keep their filename at import and nest per application/version,
which stops repeat imports doubling up. Version now comes from the same PE
detection the endpoints agent uses; when no version can be read the caller is
asked for one rather than silently storing a blank.

Applications also surface Version and a monitorable toggle, with launch path
and process name required only while monitorable is ticked. Fleet shows the
PC alias, matching PC Details.
This commit is contained in:
benjamin.baker
2026-09-07 15:04:21 -04:00
parent 79545db96e
commit 0134eaa219
17 changed files with 1600 additions and 148 deletions

View File

@@ -1,98 +0,0 @@
"""Tests for deactivating an application and what happens to its policy.
Delete here is a soft delete: the row is deactivated, not dropped. That is easy
to forget, because the generated install policy is a separate row that does NOT
follow automatically. Left alone it would keep installing an application the
catalogue reports as gone, and nothing on screen would explain why.
So the deactivation withdraws the policy as well, on the same ownership terms as
unticking the box: ours to remove, theirs to keep.
"""
from shopdb.core.models import Application
from plugins.computers.models import (ConfigurationPolicy, POLICY_SOURCE_LOCAL,
POLICY_SOURCE_AUTOAPP)
from plugins.computers.services.apppolicy import auto_policy_name
def _required_app(client, auth_headers, name='Fleet App'):
response = client.post('/api/applications',
json={'appname': name, 'isrequired': True,
'acknowledgepolicy': True},
headers=auth_headers)
assert response.status_code == 201, response.get_json()
return Application.query.filter_by(appname=name).first()
def test_deleting_an_application_deactivates_rather_than_drops_it(
client, db, auth_headers):
app = Application(appname='Plain App')
db.session.add(app)
db.session.commit()
appid = app.appid
response = client.delete(f'/api/applications/{appid}', headers=auth_headers)
assert response.status_code == 200, response.get_json()
assert db.session.get(Application, appid).isactive is False
def test_deleting_a_required_application_withdraws_its_policy(
client, db, auth_headers):
app = _required_app(client, auth_headers)
assert ConfigurationPolicy.query.filter_by(
name=auto_policy_name(app)).first() is not None
response = client.delete(f'/api/applications/{app.appid}',
headers=auth_headers)
assert response.status_code == 200, response.get_json()
# The policy must not outlive the application it installs.
assert ConfigurationPolicy.query.filter_by(
name=auto_policy_name(app)).first() is None
def test_deleting_an_application_keeps_a_customised_policy(
client, db, auth_headers):
# Same rule as unticking the box: once an admin has edited the policy it is
# theirs, and a delete on another screen must not discard their work.
app = _required_app(client, auth_headers)
policy = ConfigurationPolicy.query.filter_by(
name=auto_policy_name(app)).first()
policy.source = POLICY_SOURCE_LOCAL
db.session.commit()
response = client.delete(f'/api/applications/{app.appid}',
headers=auth_headers)
assert response.status_code == 200, response.get_json()
survivor = ConfigurationPolicy.query.filter_by(
name=auto_policy_name(app)).first()
assert survivor is not None
assert survivor.source == POLICY_SOURCE_LOCAL
def test_deleting_clears_the_required_flag(client, db, auth_headers):
# Otherwise reactivating the application later would show it as required
# while no policy enforces it.
app = _required_app(client, auth_headers)
client.delete(f'/api/applications/{app.appid}', headers=auth_headers)
assert db.session.get(Application, app.appid).isrequired is False
def test_delete_requires_the_delete_permission(client, db, auth_headers,
member_headers):
# Authenticated but unprivileged. The button is hidden for this user in the
# UI; this pins that the endpoint refuses them regardless.
app = Application(appname='Plain App')
db.session.add(app)
db.session.commit()
response = client.delete(f'/api/applications/{app.appid}',
headers=member_headers)
assert response.status_code in (401, 403), response.get_json()
assert db.session.get(Application, app.appid).isactive is not False

View File

@@ -0,0 +1,139 @@
"""Tests for permanently deleting an application.
This used to be a soft delete, and that was the bug: setting isactive=False
left the row in the table, so the name stayed reserved forever. Creating an
application with the same name failed with "already exists" pointing at a row
no screen would show. Nothing in the UI could explain or clear it.
So delete is now a real delete, and these tests pin the parts that are easy to
get wrong: the dependent rows that would otherwise orphan or block the delete,
the uploaded files that would otherwise linger on disk, and the typed
confirmation that stops it happening on a stray click.
"""
import os
from shopdb.core.models import Application, AppVersion
from plugins.computers.models import ConfigurationPolicy
from plugins.computers.services.apppolicy import auto_policy_name
def _app(db, name='Test App', **kwargs):
app = Application(appname=name, **kwargs)
db.session.add(app)
db.session.commit()
return app
def _delete(client, headers, appid, confirm='OK'):
return client.delete(f'/api/applications/{appid}',
json={'confirm': confirm} if confirm is not None else {},
headers=headers)
def test_delete_removes_the_row_entirely(client, db, auth_headers):
app = _app(db)
appid = app.appid
response = _delete(client, auth_headers, appid)
assert response.status_code == 200, response.get_json()
assert db.session.get(Application, appid) is None
def test_the_name_is_reusable_after_a_delete(client, db, auth_headers):
# The bug this change exists to fix. A soft-deleted row kept its name
# reserved and the conflict named an application the user could not see.
app = _app(db, name='DeviceLogger')
_delete(client, auth_headers, app.appid)
response = client.post('/api/applications',
json={'appname': 'DeviceLogger'},
headers=auth_headers)
assert response.status_code == 201, response.get_json()
def test_delete_is_refused_without_the_typed_confirmation(client, db,
auth_headers):
app = _app(db)
response = _delete(client, auth_headers, app.appid, confirm=None)
assert response.status_code == 400, response.get_json()
assert db.session.get(Application, app.appid) is not None
def test_delete_is_refused_when_the_confirmation_is_wrong_case(client, db,
auth_headers):
# Accepting "ok" would defeat the point of asking for deliberate input.
app = _app(db)
response = _delete(client, auth_headers, app.appid, confirm='ok')
assert response.status_code == 400, response.get_json()
assert db.session.get(Application, app.appid) is not None
def test_delete_takes_the_versions_with_it(client, db, auth_headers):
# appversions.appid is NOT NULL, so leaving these behind would either
# orphan them or block the delete outright.
app = _app(db)
db.session.add(AppVersion(appid=app.appid, version='1.0.0.0'))
db.session.commit()
appid = app.appid
response = _delete(client, auth_headers, appid)
assert response.status_code == 200, response.get_json()
assert AppVersion.query.filter_by(appid=appid).count() == 0
def test_delete_withdraws_the_generated_policy(client, db, auth_headers):
response = client.post('/api/applications',
json={'appname': 'Fleet App', 'isrequired': True,
'acknowledgepolicy': True},
headers=auth_headers)
assert response.status_code == 201, response.get_json()
app = Application.query.filter_by(appname='Fleet App').first()
assert ConfigurationPolicy.query.filter_by(
name=auto_policy_name(app)).first() is not None
_delete(client, auth_headers, app.appid)
assert ConfigurationPolicy.query.filter_by(
name=auto_policy_name(app)).first() is None
def test_delete_removes_the_uploaded_installer(client, db, auth_headers):
app = _app(db)
upload = client.post(f'/api/applications/{app.appid}/package',
data={'file': (__import__('io').BytesIO(b'MZfake'),
'setup.exe')},
content_type='multipart/form-data',
headers=auth_headers)
assert upload.status_code == 200, upload.get_json()
stored = db.session.get(Application, app.appid).installpath
assert stored
from shopdb.core.api.applications import _apppackage_dir
packagedir = os.path.join(_apppackage_dir(), str(app.appid))
# Guard the guard: if the upload did not land where we think, the
# post-delete assertion below would pass without proving anything.
assert os.listdir(packagedir) == ['setup.exe']
_delete(client, auth_headers, app.appid)
# Nothing may remain on disk for this application.
assert not os.path.isdir(packagedir)
def test_delete_requires_the_delete_permission(client, db, member_headers):
app = _app(db)
response = _delete(client, member_headers, app.appid)
assert response.status_code in (401, 403), response.get_json()
assert db.session.get(Application, app.appid) is not None

View File

@@ -0,0 +1,142 @@
"""Tests for the monitorable flag and the two fields it depends on.
A monitorable application tells the agent to watch for a process and relaunch it
when it goes missing. That needs an image name to watch for and a path to run,
so the flag is meaningless without both - a monitor with no process name has
nothing to check, and one with no launch path has nothing to do about it.
The rule lives in _validate_monitorable rather than in a DB constraint so the
columns stay nullable for rows that predate the feature. That makes it a rule
code has to keep choosing to apply, which is what these tests pin: it must hold
on create AND on update, including the update that only ticks the box and sends
nothing else.
"""
from shopdb.core.models import Application
def _app(db, name='Test App', **kwargs):
app = Application(appname=name, **kwargs)
db.session.add(app)
db.session.commit()
return app
def _create(client, headers, **fields):
payload = {'appname': 'Monitored App'}
payload.update(fields)
return client.post('/api/applications', json=payload, headers=headers)
def _update(client, headers, appid, **fields):
return client.put(f'/api/applications/{appid}', json=fields,
headers=headers)
def _message(response):
"""The error text, which sits at data.error.message rather than top level."""
return response.get_json()['data']['error']['message'].lower()
def test_creating_a_monitorable_app_needs_a_process_name(client, auth_headers):
response = _create(client, auth_headers, ismonitorable=True,
monitorlaunchpath=r'C:\Vendor\app.exe')
assert response.status_code == 400
assert 'process name' in _message(response)
def test_creating_a_monitorable_app_needs_a_launch_path(client, auth_headers):
response = _create(client, auth_headers, ismonitorable=True,
monitorprocessname='app.exe')
assert response.status_code == 400
assert 'launch path' in _message(response)
def test_a_monitorable_app_with_both_fields_saves(client, auth_headers, db):
response = _create(client, auth_headers, ismonitorable=True,
monitorprocessname='app.exe',
monitorlaunchpath=r'C:\Vendor\app.exe')
assert response.status_code == 201, response.get_json()
app = Application.query.filter_by(appname='Monitored App').first()
assert app.ismonitorable is True
assert app.monitorprocessname == 'app.exe'
def test_a_non_monitorable_app_needs_neither_field(client, auth_headers):
# The rule must not leak onto ordinary applications, which are the majority.
response = _create(client, auth_headers, ismonitorable=False)
assert response.status_code == 201, response.get_json()
def test_ticking_the_box_alone_is_refused(client, auth_headers, db):
"""The case a payload-only check would miss.
The form sends just the changed field, so an update that ticks monitorable
on a record with no launch path or process name arrives carrying nothing
else. Validating the payload in isolation would see no conflict and let it
through, storing a monitor that cannot run.
"""
app = _app(db, name='Plain App')
response = _update(client, auth_headers, app.appid, ismonitorable=True)
assert response.status_code == 400
# Launch path is checked first, so that is the field named. What matters is
# that the merged state was rejected at all, not which half it complained
# about.
assert 'launch path' in _message(response)
def test_ticking_the_box_is_allowed_when_the_row_already_has_the_fields(
client, auth_headers, db):
# The mirror of the above: validation reads the MERGED result, so stored
# values satisfy it and the operator is not asked to retype them.
app = _app(db, name='Ready App', monitorprocessname='app.exe',
monitorlaunchpath=r'C:\Vendor\app.exe')
response = _update(client, auth_headers, app.appid, ismonitorable=True)
assert response.status_code == 200, response.get_json()
assert Application.query.get(app.appid).ismonitorable is True
def test_clearing_a_field_on_a_monitorable_app_is_refused(client, auth_headers,
db):
# Blanking the process name is the same broken state as never setting it,
# so it has to be refused from this direction too.
app = _app(db, name='Live Monitor', ismonitorable=True,
monitorprocessname='app.exe',
monitorlaunchpath=r'C:\Vendor\app.exe')
response = _update(client, auth_headers, app.appid,
monitorprocessname='')
assert response.status_code == 400
assert 'process name' in _message(response)
def test_whitespace_does_not_count_as_a_process_name(client, auth_headers):
response = _create(client, auth_headers, ismonitorable=True,
monitorprocessname=' ',
monitorlaunchpath=r'C:\Vendor\app.exe')
assert response.status_code == 400
assert 'process name' in _message(response)
def test_unticking_the_box_releases_the_requirement(client, auth_headers, db):
# Turning monitoring off must not be blocked by the fields it needed while
# it was on, or an operator cannot undo a mistake without a round trip.
app = _app(db, name='Live Monitor', ismonitorable=True,
monitorprocessname='app.exe',
monitorlaunchpath=r'C:\Vendor\app.exe')
response = _update(client, auth_headers, app.appid, ismonitorable=False,
monitorprocessname='', monitorlaunchpath='')
assert response.status_code == 200, response.get_json()
assert Application.query.get(app.appid).ismonitorable is False

View File

@@ -0,0 +1,196 @@
"""Tests for reverting an application back to a plain catalogue record.
Revert is the middle ground between editing and deleting: it withdraws the
generated policy, drops the version rows and removes the uploaded executables,
while the application itself stays in the catalogue.
The line these tests defend is what revert must NOT touch. The warning offers
to remove "policy associations and uploaded executables" and nothing else, so
the image, the name, the description and the knowledgebase links all have to
survive. An operator who reverts to clean up an installer should not discover
afterwards that the record lost its icon.
"""
import io
import os
from shopdb.core.models import Application, AppVersion
from plugins.computers.models import ConfigurationPolicy
from plugins.computers.services.apppolicy import auto_policy_name
def _app(db, name='Test App', **kwargs):
app = Application(appname=name, **kwargs)
db.session.add(app)
db.session.commit()
return app
def _revert(client, headers, appid, confirm='OK'):
return client.post(f'/api/applications/{appid}/revert',
json={'confirm': confirm} if confirm is not None else {},
headers=headers)
def _upload_package(client, headers, appid, name='setup.exe', data=b'MZfake'):
return client.post(f'/api/applications/{appid}/package',
data={'file': (io.BytesIO(data), name)},
content_type='multipart/form-data',
headers=headers)
def _upload_image(client, headers, appid):
# A one-pixel PNG; the route only needs a plausible image file.
png = (b'\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00'
b'\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\nIDATx\x9cc'
b'\x00\x01\x00\x00\x05\x00\x01\r\n-\xb4\x00\x00\x00\x00IEND\xaeB`'
b'\x82')
return client.post(f'/api/applications/{appid}/image',
data={'file': (io.BytesIO(png), 'icon.png')},
content_type='multipart/form-data',
headers=headers)
def test_revert_keeps_the_application_row(client, db, auth_headers):
# The whole point of revert over delete: the record survives.
app = _app(db, name='DeviceLogger')
appid = app.appid
response = _revert(client, auth_headers, appid)
assert response.status_code == 200, response.get_json()
survivor = db.session.get(Application, appid)
assert survivor is not None
assert survivor.appname == 'DeviceLogger'
def test_revert_keeps_the_image(client, db, auth_headers):
# The warning offers to remove policy associations and executables. It says
# nothing about the icon, and a record kept on purpose should keep it.
app = _app(db)
appid = app.appid
assert _upload_image(client, auth_headers, appid).status_code == 200
stored = db.session.get(Application, appid).image
assert stored
response = _revert(client, auth_headers, appid)
assert response.status_code == 200, response.get_json()
assert db.session.get(Application, appid).image == stored
from shopdb.core.api.applications import _appimage_dir
surviving = [name for name in os.listdir(_appimage_dir())
if name.startswith(f'application-{appid}.')]
assert surviving, 'the image file was deleted from disk'
def test_revert_removes_the_uploaded_executables(client, db, auth_headers):
app = _app(db)
appid = app.appid
assert _upload_package(client, auth_headers, appid).status_code == 200
from shopdb.core.api.applications import _apppackage_dir
packagedir = os.path.join(_apppackage_dir(), str(appid))
assert os.listdir(packagedir) == ['setup.exe']
response = _revert(client, auth_headers, appid)
assert response.status_code == 200, response.get_json()
assert not os.path.isdir(packagedir)
reverted = db.session.get(Application, appid)
assert reverted.installpath is None
assert reverted.installersha256 is None
def test_revert_leaves_an_operator_typed_install_path_alone(client, db,
auth_headers):
# A UNC path points at the site's own share, not our upload directory, and
# is not ours to wipe.
share = r'\\fileserver\software\DeviceLogger\setup.exe'
app = _app(db, installpath=share)
response = _revert(client, auth_headers, app.appid)
assert response.status_code == 200, response.get_json()
assert db.session.get(Application, app.appid).installpath == share
def test_revert_takes_the_versions_with_it(client, db, auth_headers):
app = _app(db)
appid = app.appid
db.session.add(AppVersion(appid=appid, version='1.0.0.0'))
db.session.commit()
response = _revert(client, auth_headers, appid)
assert response.status_code == 200, response.get_json()
assert AppVersion.query.filter_by(appid=appid).count() == 0
def test_revert_withdraws_the_generated_policy(client, db, auth_headers):
created = client.post('/api/applications',
json={'appname': 'Fleet App', 'isrequired': True,
'acknowledgepolicy': True},
headers=auth_headers)
assert created.status_code == 201, created.get_json()
app = Application.query.filter_by(appname='Fleet App').first()
assert ConfigurationPolicy.query.filter_by(
name=auto_policy_name(app)).first() is not None
response = _revert(client, auth_headers, app.appid)
assert response.status_code == 200, response.get_json()
assert ConfigurationPolicy.query.filter_by(
name=auto_policy_name(app)).first() is None
assert db.session.get(Application, app.appid).isrequired is False
def test_revert_keeps_the_inventory_links(client, db, auth_headers):
# A machine still has the software installed whether or not we ship an
# installer for it. Dropping these would make the fleet look clean.
from plugins.computers.models import Computer, ComputerInstalledApp
app = _app(db)
computer = Computer(assetid='PC-001')
db.session.add(computer)
db.session.commit()
db.session.add(ComputerInstalledApp(computerid=computer.computerid,
appid=app.appid))
db.session.commit()
response = _revert(client, auth_headers, app.appid)
assert response.status_code == 200, response.get_json()
assert ComputerInstalledApp.query.filter_by(appid=app.appid).count() == 1
def test_revert_is_refused_without_the_typed_confirmation(client, db,
auth_headers):
app = _app(db)
assert _upload_package(client, auth_headers, app.appid).status_code == 200
response = _revert(client, auth_headers, app.appid, confirm=None)
assert response.status_code == 400, response.get_json()
# Nothing may have been removed on a refused call.
from shopdb.core.api.applications import _apppackage_dir
assert os.path.isdir(os.path.join(_apppackage_dir(), str(app.appid)))
def test_revert_is_refused_when_the_confirmation_is_wrong_case(client, db,
auth_headers):
app = _app(db)
response = _revert(client, auth_headers, app.appid, confirm='ok')
assert response.status_code == 400, response.get_json()
def test_revert_requires_the_edit_permission(client, db, member_headers):
app = _app(db)
response = _revert(client, member_headers, app.appid)
assert response.status_code in (401, 403), response.get_json()

View File

@@ -77,11 +77,15 @@ def test_installer_upload_sets_the_install_path(client, db, auth_headers):
assert response.status_code == 200, response.get_json()
body = response.get_json()['data']
# The installer keeps the name the vendor shipped, namespaced by
# application id so a generic "setup.msi" from two vendors cannot collide.
# The old application-<id>.msi told the operator nothing and made a
# re-import look like new software.
assert body['installpath'] == \
f'/api/applications/package/application-{app.appid}.msi'
# The name the vendor shipped is reported back, since the stored copy is
# renamed and the uploader needs to recognise what they sent.
f'/api/applications/package/{app.appid}/setup.msi'
assert body['uploadedfilename'] == 'setup.msi'
# An MSI is not a PE file, so there is no version resource to read.
assert body['detectedversion'] is None
def test_installer_download_needs_a_token(client, db, auth_headers):
@@ -90,7 +94,7 @@ def test_installer_download_needs_a_token(client, db, auth_headers):
client.post(f'/api/applications/{app.appid}/package',
data={'file': (io.BytesIO(b'fake installer'), 'setup.msi')},
content_type='multipart/form-data', headers=auth_headers)
path = f'/api/applications/package/application-{app.appid}.msi'
path = f'/api/applications/package/{app.appid}/setup.msi'
assert client.get(path).status_code == 401

View File

@@ -0,0 +1,96 @@
"""Tests for reading an installer's version at upload time.
Duplicate application and version rows were being created because an operator
imported the same installer twice: nothing on screen told them the file already
matched a version in the catalogue. Reading the version out of the installer at
upload closes that, and saying so when there is no version to read closes the
rest of it.
Building a real PE with a RT_VERSION resource inline is impractical (see the
agentstore unit tests, which cover the parser itself), so these tests
monkeypatch read_pe_file_version and focus on the wiring: that the upload route
calls it, reports what it found, and reports None rather than guessing.
"""
import io
from shopdb.core.models import Application
from plugins.endpoints.api import agentstore
_PE_BYTES = b'MZ' + b'\x00' * 128
def _app(db, name='Test App'):
app = Application(appname=name)
db.session.add(app)
db.session.commit()
return app
def _upload(client, headers, appid, name='setup.exe', data=_PE_BYTES):
return client.post(f'/api/applications/{appid}/package',
data={'file': (io.BytesIO(data), name)},
content_type='multipart/form-data',
headers=headers)
def test_upload_reports_the_detected_version(client, db, auth_headers,
monkeypatch):
monkeypatch.setattr(agentstore, 'read_pe_file_version',
lambda data: '2.4.1.0')
app = _app(db)
response = _upload(client, auth_headers, app.appid)
assert response.status_code == 200, response.get_json()
assert response.get_json()['data']['detectedversion'] == '2.4.1.0'
def test_upload_reports_none_when_there_is_no_version_resource(
client, db, auth_headers, monkeypatch):
# None is the signal the form uses to ask the operator for a version. It
# must not be substituted with a placeholder, or the duplicate-import
# problem comes straight back.
monkeypatch.setattr(agentstore, 'read_pe_file_version', lambda data: None)
app = _app(db)
response = _upload(client, auth_headers, app.appid)
assert response.status_code == 200, response.get_json()
assert response.get_json()['data']['detectedversion'] is None
def test_detection_does_not_consume_the_stream(client, db, auth_headers,
monkeypatch):
# Version detection reads the upload before it is saved. If it did not
# rewind, the stored installer would be truncated or empty - which would
# not fail any assertion about the version, only corrupt the payload.
monkeypatch.setattr(agentstore, 'read_pe_file_version',
lambda data: '1.0.0.0')
app = _app(db)
assert _upload(client, auth_headers, app.appid).status_code == 200
download = client.get(
f'/api/applications/package/{app.appid}/setup.exe',
headers=auth_headers)
assert download.status_code == 200
assert download.data == _PE_BYTES
def test_detection_failure_does_not_fail_the_upload(client, db, auth_headers,
monkeypatch):
# A version is a convenience; losing it must not cost the operator the
# upload of a 500MB installer.
def _boom(data):
raise ValueError('malformed resource directory')
monkeypatch.setattr(agentstore, 'read_pe_file_version', _boom)
app = _app(db)
response = _upload(client, auth_headers, app.appid)
assert response.status_code == 200, response.get_json()
assert response.get_json()['data']['detectedversion'] is None

View File

@@ -0,0 +1,156 @@
"""Tests for the migration that nests uploaded installers by application id.
The migration rewrites installpath for every affected row and moves the file on
disk. Both halves matter: a row left on the old scheme is a download that 404s
against a route that no longer serves it, and a file left behind is a download
that 404s against a row pointing at nothing.
These call upgrade()/downgrade() directly with a stubbed bind rather than
running Alembic, so the file movement can be checked against a real temporary
directory without a database round trip.
"""
import importlib.util
import os
import pytest
def _load_migration():
path = os.path.join('migrations', 'versions', '7d46_nest_app_packages.py')
spec = importlib.util.spec_from_file_location('nest_app_packages', path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
class _FakeResult:
def __init__(self, rows):
self._rows = rows
def fetchall(self):
return self._rows
class _FakeBind:
"""Records UPDATEs and replays a fixed SELECT, so no DB is needed."""
def __init__(self, rows):
self._rows = rows
self.updates = []
def execute(self, statement, params=None):
text = str(statement)
if text.strip().startswith('SELECT'):
return _FakeResult(self._rows)
self.updates.append(params)
return _FakeResult([])
@pytest.fixture
def migration():
return _load_migration()
def _patch(monkeypatch, migration, bind, directory):
monkeypatch.setattr(migration.op, 'get_bind', lambda: bind)
monkeypatch.setattr(migration, '_package_dir', lambda: directory)
def test_upgrade_moves_the_file_and_repoints_the_row(monkeypatch, migration,
tmp_path):
directory = str(tmp_path)
flat = os.path.join(directory, 'application-7.msi')
with open(flat, 'wb') as handle:
handle.write(b'installer bytes')
bind = _FakeBind([(7, '/api/applications/package/application-7.msi')])
_patch(monkeypatch, migration, bind, directory)
migration.upgrade()
assert not os.path.exists(flat)
moved = os.path.join(directory, '7', 'installer.msi')
assert os.path.isfile(moved)
with open(moved, 'rb') as handle:
assert handle.read() == b'installer bytes'
assert bind.updates == [
{'path': '/api/applications/package/7/installer.msi', 'appid': 7}]
def test_upgrade_repoints_the_row_even_when_the_file_is_missing(
monkeypatch, migration, tmp_path):
# A row left on the old scheme is unserviceable and cannot be repaired by
# re-uploading, so the rewrite must not be conditional on the file.
bind = _FakeBind([(9, '/api/applications/package/application-9.exe')])
_patch(monkeypatch, migration, bind, str(tmp_path))
migration.upgrade()
assert bind.updates == [
{'path': '/api/applications/package/9/installer.exe', 'appid': 9}]
def test_upgrade_leaves_already_nested_rows_alone(monkeypatch, migration,
tmp_path):
# Re-running the migration must not rewrite a path that is already correct,
# which would clobber a real vendor filename with "installer.exe".
bind = _FakeBind([(3, '/api/applications/package/3/DeviceLoggerSetup.exe')])
_patch(monkeypatch, migration, bind, str(tmp_path))
migration.upgrade()
assert bind.updates == []
def test_upgrade_ignores_an_operator_typed_share_path(monkeypatch, migration,
tmp_path):
# The SELECT only matches our own prefix, but prove a UNC path is untouched.
bind = _FakeBind([])
_patch(monkeypatch, migration, bind, str(tmp_path))
migration.upgrade()
assert bind.updates == []
def test_downgrade_puts_the_file_back(monkeypatch, migration, tmp_path):
directory = str(tmp_path)
nested = os.path.join(directory, '7')
os.makedirs(nested)
with open(os.path.join(nested, 'installer.msi'), 'wb') as handle:
handle.write(b'installer bytes')
bind = _FakeBind([(7, '/api/applications/package/7/installer.msi')])
_patch(monkeypatch, migration, bind, directory)
migration.downgrade()
flat = os.path.join(directory, 'application-7.msi')
assert os.path.isfile(flat)
# The emptied directory is cleaned up so a re-upgrade does not find it.
assert not os.path.isdir(nested)
assert bind.updates == [
{'path': '/api/applications/package/application-7.msi', 'appid': 7}]
def test_upgrade_then_downgrade_round_trips(monkeypatch, migration, tmp_path):
directory = str(tmp_path)
with open(os.path.join(directory, 'application-4.exe'), 'wb') as handle:
handle.write(b'payload')
up = _FakeBind([(4, '/api/applications/package/application-4.exe')])
_patch(monkeypatch, migration, up, directory)
migration.upgrade()
down = _FakeBind([(4, up.updates[0]['path'])])
_patch(monkeypatch, migration, down, directory)
migration.downgrade()
restored = os.path.join(directory, 'application-4.exe')
assert os.path.isfile(restored)
with open(restored, 'rb') as handle:
assert handle.read() == b'payload'
assert down.updates[0]['path'] == \
'/api/applications/package/application-4.exe'

View File

@@ -0,0 +1,110 @@
"""The Fleet list must carry the asset alias.
The alias is what people actually call a PC ("Bay 3 Controller"); the hostname
is what DNS calls it. The PCs list has shown both for a long time, but the
Fleet list query never selected Asset.name, so the column could not be built
even though the data existed - it was missing rather than broken.
These pin the field on the payload and its inclusion in search, so the column
cannot quietly lose its source again.
"""
LIST_URL = '/api/endpoints/'
def _endpoint(db, hostname='fleetbox', alias='Bay 3 Controller'):
"""An Asset + Computer + EndpointState triple: one row on the fleet list.
The list joins through Computer and filters on Asset.isactive, so all three
are required for a row to appear at all.
"""
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=alias,
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 _rows(response):
return response.get_json()['data']
def test_the_fleet_list_carries_the_alias(client, db, auth_headers):
_endpoint(db)
response = client.get(LIST_URL, headers=auth_headers)
assert response.status_code == 200, response.get_json()
rows = _rows(response)
assert len(rows) == 1
# Exposed as 'alias', not 'name': the row already has a hostname, and
# 'name' beside 'hostname' reads as a duplicate of the same thing.
assert rows[0]['alias'] == 'Bay 3 Controller'
def test_an_endpoint_without_an_alias_still_lists(client, db, auth_headers):
# The column renders '-' for these. A null must not drop the row or 500.
_endpoint(db, hostname='namelessbox', alias=None)
response = client.get(LIST_URL, headers=auth_headers)
assert response.status_code == 200, response.get_json()
rows = _rows(response)
assert len(rows) == 1
assert rows[0]['alias'] is None
assert rows[0]['hostname'] == 'namelessbox'
def test_the_alias_is_searchable(client, db, auth_headers):
# Someone looking for a PC types what it is called, not its hostname.
_endpoint(db, hostname='pc-a', alias='Bay 3 Controller')
_endpoint(db, hostname='pc-b', alias='Paint Booth')
response = client.get(LIST_URL + '?search=Bay 3', headers=auth_headers)
assert response.status_code == 200, response.get_json()
rows = _rows(response)
assert [r['hostname'] for r in rows] == ['pc-a']
def test_searching_by_hostname_still_works(client, db, auth_headers):
# The alias clause was added to an existing OR; prove it did not replace
# what was already there.
_endpoint(db, hostname='pc-a', alias='Bay 3 Controller')
_endpoint(db, hostname='pc-b', alias='Paint Booth')
response = client.get(LIST_URL + '?search=pc-b', headers=auth_headers)
assert response.status_code == 200, response.get_json()
rows = _rows(response)
assert [r['hostname'] for r in rows] == ['pc-b']
def test_the_row_keeps_its_other_columns(client, db, auth_headers):
# The alias was added mid-tuple in the query, so every following column
# shifted position. If that unpacking is wrong the values silently swap
# rather than raising.
_endpoint(db, hostname='fleetbox', alias='Bay 3 Controller')
response = client.get(LIST_URL, headers=auth_headers)
row = _rows(response)[0]
assert row['hostname'] == 'fleetbox'
assert row['assetnumber'] == 'fleetbox'
assert row['alias'] == 'Bay 3 Controller'
assert row['status'] in ('never', 'quiet', 'reporting')
assert 'computerid' in row and row['computerid'] is not None