Initial import: shopdb-flask multi-site asset platform
Flask + Vue rewrite of the classic-ASP shopdb, built as a plugin framework so sister GE Aerospace sites can adopt it. Product version 0.7.0-pre-0.8.0, plugin contract 0.11.0, 966 backend tests. Key entry points: - docs/INSTALL-WINDOWS-IIS.md - tested Windows + IIS install runbook (own-site and subpath deployment methods) - docs/PILOT-DEPLOY.md - production pilot: stand up + import + verify - docs/IMPORT-API.md - schema-agnostic legacy data import contract - scripts/site_imports/wjf/ - West Jefferson reference import loader - docs/adr/ - twelve accepted architecture decision records
This commit is contained in:
0
tests/test_plugins/__init__.py
Normal file
0
tests/test_plugins/__init__.py
Normal file
163
tests/test_plugins/test_dualpath_single.py
Normal file
163
tests/test_plugins/test_dualpath_single.py
Normal file
@@ -0,0 +1,163 @@
|
||||
"""Dualpath single-machine collapse (site setting dualpath_single_machine).
|
||||
|
||||
A Dualpath pair is one physical dual-bay machine recorded as two asset rows.
|
||||
With the setting on (default), the machines list, dashboard machine count, and
|
||||
the floor map collapse the pair to the PRIMARY bay (lower natural-sort
|
||||
assetnumber) and annotate it with its hidden partner. With the setting off,
|
||||
both bays list and count separately. The data model always keeps both rows.
|
||||
|
||||
Settings are cached, so each test invalidates the cache after seeding.
|
||||
"""
|
||||
|
||||
from shopdb.extensions import db as _db
|
||||
from shopdb.core.models import Asset, AssetType, Setting
|
||||
from shopdb.core.models.relationship import RelationshipType, AssetRelationship
|
||||
from shopdb.core.api.settings import invalidate_settings_cache
|
||||
|
||||
from plugins.machines.models import Machine
|
||||
|
||||
|
||||
def _seed_dualpath_pair():
|
||||
"""Create a 'machine' AssetType, two machine assets (2007, 2008) placed on
|
||||
the map, and an active Dualpath relationship between them. 2007 is PRIMARY
|
||||
(lower natural-sort). Returns (primary_machine, secondary_machine)."""
|
||||
machinetype = AssetType(assettype='machine')
|
||||
_db.session.add(machinetype)
|
||||
_db.session.flush()
|
||||
|
||||
dualpath = RelationshipType(relationshiptype='Dualpath', isdirectional=False)
|
||||
_db.session.add(dualpath)
|
||||
_db.session.flush()
|
||||
|
||||
primary_asset = Asset(assetnumber='2007', assettypeid=machinetype.assettypeid,
|
||||
mapx=100, mapy=100)
|
||||
secondary_asset = Asset(assetnumber='2008', assettypeid=machinetype.assettypeid,
|
||||
mapx=110, mapy=110)
|
||||
_db.session.add_all([primary_asset, secondary_asset])
|
||||
_db.session.flush()
|
||||
|
||||
primary_machine = Machine(assetid=primary_asset.assetid)
|
||||
secondary_machine = Machine(assetid=secondary_asset.assetid)
|
||||
_db.session.add_all([primary_machine, secondary_machine])
|
||||
_db.session.flush()
|
||||
|
||||
_db.session.add(AssetRelationship(
|
||||
sourceassetid=primary_asset.assetid,
|
||||
targetassetid=secondary_asset.assetid,
|
||||
relationshiptypeid=dualpath.relationshiptypeid,
|
||||
))
|
||||
_db.session.commit()
|
||||
return primary_machine, secondary_machine
|
||||
|
||||
|
||||
def _set_toggle(value):
|
||||
Setting.set('dualpath_single_machine', value, valuetype='boolean',
|
||||
category='site')
|
||||
invalidate_settings_cache()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Setting ON (default): pair collapses to the primary bay
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_list_collapses_secondary_and_annotates_primary(client, db):
|
||||
invalidate_settings_cache() # default on (no row)
|
||||
_seed_dualpath_pair()
|
||||
|
||||
resp = client.get('/api/machines')
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
payload = resp.get_json()
|
||||
items = payload['data']
|
||||
numbers = [i['assetnumber'] for i in items]
|
||||
|
||||
# secondary bay (2008) is hidden; total reflects the collapse
|
||||
assert numbers == ['2007']
|
||||
assert payload['meta']['pagination']['total'] == 1
|
||||
|
||||
# the visible primary carries its partner
|
||||
partner = items[0]['dualpathpartner']
|
||||
assert partner is not None
|
||||
assert partner['assetnumber'] == '2008'
|
||||
assert partner['assetid'] is not None
|
||||
assert partner['machineid'] is not None
|
||||
|
||||
|
||||
def test_dashboard_machine_count_collapses(client, db):
|
||||
invalidate_settings_cache()
|
||||
_seed_dualpath_pair()
|
||||
|
||||
resp = client.get('/api/dashboard')
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
counts = resp.get_json()['data']
|
||||
# two machine rows, one physical machine
|
||||
assert counts['totalmachines'] == 1
|
||||
assert counts['counts']['machines'] == 1
|
||||
|
||||
|
||||
def test_map_excludes_secondary_marker(client, db):
|
||||
invalidate_settings_cache()
|
||||
_seed_dualpath_pair()
|
||||
|
||||
resp = client.get('/api/assets/map')
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
assets = resp.get_json()['data']['assets']
|
||||
numbers = [a['assetnumber'] for a in assets]
|
||||
|
||||
assert '2007' in numbers
|
||||
assert '2008' not in numbers
|
||||
primary = next(a for a in assets if a['assetnumber'] == '2007')
|
||||
assert primary['dualpathpartner'] == '2008'
|
||||
|
||||
|
||||
def test_detail_banner_partner_shown_regardless_of_toggle(client, db):
|
||||
invalidate_settings_cache()
|
||||
primary_machine, secondary_machine = _seed_dualpath_pair()
|
||||
|
||||
# banner data is present on BOTH bays even with the toggle off
|
||||
_set_toggle(False)
|
||||
for machine_id, expected in (
|
||||
(primary_machine.machineid, '2008'),
|
||||
(secondary_machine.machineid, '2007'),
|
||||
):
|
||||
resp = client.get(f'/api/machines/{machine_id}')
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
partner = resp.get_json()['data']['dualpathpartner']
|
||||
assert partner is not None
|
||||
assert partner['assetnumber'] == expected
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Setting OFF: both bays list and count separately
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_list_shows_both_bays_when_disabled(client, db):
|
||||
_seed_dualpath_pair()
|
||||
_set_toggle(False)
|
||||
|
||||
resp = client.get('/api/machines')
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
payload = resp.get_json()
|
||||
numbers = sorted(i['assetnumber'] for i in payload['data'])
|
||||
|
||||
assert numbers == ['2007', '2008']
|
||||
assert payload['meta']['pagination']['total'] == 2
|
||||
|
||||
|
||||
def test_dashboard_counts_both_bays_when_disabled(client, db):
|
||||
_seed_dualpath_pair()
|
||||
_set_toggle(False)
|
||||
|
||||
resp = client.get('/api/dashboard')
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
assert resp.get_json()['data']['totalmachines'] == 2
|
||||
|
||||
|
||||
def test_map_shows_both_bays_when_disabled(client, db):
|
||||
_seed_dualpath_pair()
|
||||
_set_toggle(False)
|
||||
|
||||
resp = client.get('/api/assets/map')
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
numbers = [a['assetnumber'] for a in resp.get_json()['data']['assets']]
|
||||
assert '2007' in numbers
|
||||
assert '2008' in numbers
|
||||
212
tests/test_plugins/test_employee_photo.py
Normal file
212
tests/test_plugins/test_employee_photo.py
Normal file
@@ -0,0 +1,212 @@
|
||||
"""Tests for self-hosted employee photo upload, replace, delete, serve, and the
|
||||
shared photo-URL resolver used by both EmployeeDetail and the kiosk cards.
|
||||
|
||||
Covers: admin-gated upload/delete, the public serve route, one-photo-per-person
|
||||
replace semantics, external-mode 409s, and resolve_employee_photo_url in both
|
||||
directory modes. The wjf_employees HR DB is not available under test, so external
|
||||
resolution is exercised by passing the Picture value directly (no query)."""
|
||||
|
||||
import io
|
||||
import os
|
||||
|
||||
from shopdb.core.models import Setting
|
||||
from plugins.employees.models import DirectoryEmployee
|
||||
from plugins.employees.api.routes import (
|
||||
resolve_employee_photo_url,
|
||||
EMPLOYEE_PHOTO_URL_PREFIX,
|
||||
EMPLOYEE_PHOTO_STATIC_PREFIX,
|
||||
)
|
||||
|
||||
|
||||
def _set_mode(db, mode):
|
||||
row = Setting.query.filter_by(key='employee_directory_mode').first()
|
||||
if row:
|
||||
row.value = mode
|
||||
else:
|
||||
db.session.add(Setting(key='employee_directory_mode', value=mode,
|
||||
valuetype='string', category='site'))
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def _make_employee(db, sso=210000001):
|
||||
emp = DirectoryEmployee(sso=sso, firstname='Test', lastname='Person')
|
||||
db.session.add(emp)
|
||||
db.session.commit()
|
||||
return emp
|
||||
|
||||
|
||||
# --- upload / replace / delete (self-hosted) --------------------------------
|
||||
|
||||
def test_upload_forbidden_for_non_admin(client, db, member_headers):
|
||||
"""A role-less authenticated user cannot upload an employee photo."""
|
||||
_set_mode(db, 'selfhosted')
|
||||
emp = _make_employee(db)
|
||||
data = {'file': (io.BytesIO(b'bytes'), 'p.png')}
|
||||
resp = client.post(f'/api/employees/{emp.sso}/photo', data=data,
|
||||
content_type='multipart/form-data', headers=member_headers)
|
||||
assert resp.status_code == 403
|
||||
assert resp.get_json()['data']['error']['code'] == 'FORBIDDEN'
|
||||
|
||||
|
||||
def test_upload_sets_photofilename_and_file_exists(client, db, auth_headers, app):
|
||||
"""Admin upload writes photofilename + photourl and lands the file on disk."""
|
||||
_set_mode(db, 'selfhosted')
|
||||
emp = _make_employee(db)
|
||||
payload = b'\x89PNG\r\n\x1a\nfake-png'
|
||||
data = {'file': (io.BytesIO(payload), 'p.png')}
|
||||
resp = client.post(f'/api/employees/{emp.sso}/photo', data=data,
|
||||
content_type='multipart/form-data', headers=auth_headers)
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
|
||||
filename = f'photo-{emp.sso}.png'
|
||||
body = resp.get_json()['data']
|
||||
assert body['photofilename'] == filename
|
||||
assert body['photourl'] == f'{EMPLOYEE_PHOTO_URL_PREFIX}{filename}'
|
||||
|
||||
refreshed = db.session.get(DirectoryEmployee, emp.sso)
|
||||
assert refreshed.photofilename == filename
|
||||
|
||||
path = os.path.join(app.instance_path, 'employeephotos', filename)
|
||||
assert os.path.exists(path)
|
||||
with open(path, 'rb') as handle:
|
||||
assert handle.read() == payload
|
||||
|
||||
|
||||
def test_upload_rejects_invalid_extension(client, db, auth_headers):
|
||||
"""A disallowed file extension is rejected."""
|
||||
_set_mode(db, 'selfhosted')
|
||||
emp = _make_employee(db)
|
||||
data = {'file': (io.BytesIO(b'MZ'), 'p.exe')}
|
||||
resp = client.post(f'/api/employees/{emp.sso}/photo', data=data,
|
||||
content_type='multipart/form-data', headers=auth_headers)
|
||||
assert resp.status_code == 400
|
||||
assert resp.get_json()['data']['error']['code'] == 'VALIDATION_ERROR'
|
||||
|
||||
|
||||
def test_upload_missing_employee_is_404(client, db, auth_headers):
|
||||
"""Uploading to a nonexistent SSO is a 404."""
|
||||
_set_mode(db, 'selfhosted')
|
||||
data = {'file': (io.BytesIO(b'x'), 'p.png')}
|
||||
resp = client.post('/api/employees/999999/photo', data=data,
|
||||
content_type='multipart/form-data', headers=auth_headers)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_reupload_replaces_and_removes_old_extension(client, db, auth_headers, app):
|
||||
"""Re-upload with a different extension deletes the prior file."""
|
||||
_set_mode(db, 'selfhosted')
|
||||
emp = _make_employee(db)
|
||||
first = {'file': (io.BytesIO(b'first'), 'p.png')}
|
||||
client.post(f'/api/employees/{emp.sso}/photo', data=first,
|
||||
content_type='multipart/form-data', headers=auth_headers)
|
||||
oldpath = os.path.join(app.instance_path, 'employeephotos', f'photo-{emp.sso}.png')
|
||||
assert os.path.exists(oldpath)
|
||||
|
||||
second = {'file': (io.BytesIO(b'second'), 'p.jpg')}
|
||||
resp = client.post(f'/api/employees/{emp.sso}/photo', data=second,
|
||||
content_type='multipart/form-data', headers=auth_headers)
|
||||
assert resp.status_code == 200
|
||||
|
||||
assert not os.path.exists(oldpath)
|
||||
newpath = os.path.join(app.instance_path, 'employeephotos', f'photo-{emp.sso}.jpg')
|
||||
assert os.path.exists(newpath)
|
||||
refreshed = db.session.get(DirectoryEmployee, emp.sso)
|
||||
assert refreshed.photofilename == f'photo-{emp.sso}.jpg'
|
||||
|
||||
|
||||
def test_serve_returns_bytes(client, db, auth_headers):
|
||||
"""The public serve route returns the uploaded bytes without auth."""
|
||||
_set_mode(db, 'selfhosted')
|
||||
emp = _make_employee(db)
|
||||
payload = b'\x89PNG\r\n\x1a\nserved'
|
||||
data = {'file': (io.BytesIO(payload), 'p.png')}
|
||||
client.post(f'/api/employees/{emp.sso}/photo', data=data,
|
||||
content_type='multipart/form-data', headers=auth_headers)
|
||||
|
||||
served = client.get(f'{EMPLOYEE_PHOTO_URL_PREFIX}photo-{emp.sso}.png')
|
||||
assert served.status_code == 200
|
||||
assert served.get_data() == payload
|
||||
|
||||
|
||||
def test_delete_clears_field_and_removes_file(client, db, auth_headers, app):
|
||||
"""Delete clears photofilename and removes the uploaded file."""
|
||||
_set_mode(db, 'selfhosted')
|
||||
emp = _make_employee(db)
|
||||
data = {'file': (io.BytesIO(b'bytes'), 'p.png')}
|
||||
client.post(f'/api/employees/{emp.sso}/photo', data=data,
|
||||
content_type='multipart/form-data', headers=auth_headers)
|
||||
path = os.path.join(app.instance_path, 'employeephotos', f'photo-{emp.sso}.png')
|
||||
assert os.path.exists(path)
|
||||
|
||||
resp = client.delete(f'/api/employees/{emp.sso}/photo', headers=auth_headers)
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json()['data']['photourl'] is None
|
||||
assert not os.path.exists(path)
|
||||
|
||||
refreshed = db.session.get(DirectoryEmployee, emp.sso)
|
||||
assert refreshed.photofilename is None
|
||||
|
||||
|
||||
def test_delete_forbidden_for_non_admin(client, db, member_headers):
|
||||
"""A role-less authenticated user cannot delete an employee photo."""
|
||||
_set_mode(db, 'selfhosted')
|
||||
emp = _make_employee(db)
|
||||
resp = client.delete(f'/api/employees/{emp.sso}/photo', headers=member_headers)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
# --- external-mode 409 ------------------------------------------------------
|
||||
|
||||
def test_upload_conflict_in_external_mode(client, db, auth_headers):
|
||||
"""Upload is a 409 when the directory is external (HR owns the photo)."""
|
||||
_set_mode(db, 'external')
|
||||
data = {'file': (io.BytesIO(b'x'), 'p.png')}
|
||||
resp = client.post('/api/employees/210000001/photo', data=data,
|
||||
content_type='multipart/form-data', headers=auth_headers)
|
||||
assert resp.status_code == 409
|
||||
assert resp.get_json()['data']['error']['code'] == 'CONFLICT'
|
||||
|
||||
|
||||
def test_delete_conflict_in_external_mode(client, db, auth_headers):
|
||||
"""Delete is a 409 when the directory is external."""
|
||||
_set_mode(db, 'external')
|
||||
resp = client.delete('/api/employees/210000001/photo', headers=auth_headers)
|
||||
assert resp.status_code == 409
|
||||
|
||||
|
||||
# --- resolver ---------------------------------------------------------------
|
||||
|
||||
def test_resolver_selfhosted_returns_upload_url_or_none(client, db, auth_headers):
|
||||
"""Self-hosted: served upload URL when a photo exists, else None."""
|
||||
_set_mode(db, 'selfhosted')
|
||||
emp = _make_employee(db)
|
||||
assert resolve_employee_photo_url(emp.sso) is None
|
||||
|
||||
data = {'file': (io.BytesIO(b'x'), 'p.png')}
|
||||
client.post(f'/api/employees/{emp.sso}/photo', data=data,
|
||||
content_type='multipart/form-data', headers=auth_headers)
|
||||
assert resolve_employee_photo_url(emp.sso) == f'{EMPLOYEE_PHOTO_URL_PREFIX}photo-{emp.sso}.png'
|
||||
|
||||
|
||||
def test_resolver_external_prefixes_relative_and_passes_urls(db):
|
||||
"""External: relative HR paths get the static prefix; absolute URLs pass through."""
|
||||
_set_mode(db, 'external')
|
||||
assert resolve_employee_photo_url(210000001, 'Support/210000001.png') == \
|
||||
f'{EMPLOYEE_PHOTO_STATIC_PREFIX}Support/210000001.png'
|
||||
assert resolve_employee_photo_url(210000001, 'https://hr.example.net/p.png') == \
|
||||
'https://hr.example.net/p.png'
|
||||
assert resolve_employee_photo_url(210000001, None) is None
|
||||
assert resolve_employee_photo_url(None) is None
|
||||
|
||||
|
||||
def test_lookup_includes_photourl(client, db, auth_headers):
|
||||
"""The lookup serializer carries the resolved photourl (self-hosted)."""
|
||||
_set_mode(db, 'selfhosted')
|
||||
emp = _make_employee(db)
|
||||
data = {'file': (io.BytesIO(b'x'), 'p.png')}
|
||||
client.post(f'/api/employees/{emp.sso}/photo', data=data,
|
||||
content_type='multipart/form-data', headers=auth_headers)
|
||||
|
||||
resp = client.get(f'/api/employees/lookup/{emp.sso}')
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json()['data']['photourl'] == f'{EMPLOYEE_PHOTO_URL_PREFIX}photo-{emp.sso}.png'
|
||||
166
tests/test_plugins/test_geenforce_compliance.py
Normal file
166
tests/test_plugins/test_geenforce_compliance.py
Normal file
@@ -0,0 +1,166 @@
|
||||
"""GE-Enforce compliance: fleet-install coverage per app-linked entry.
|
||||
|
||||
Compliance pairs each app-linked manifest entry with collected PC data
|
||||
(ComputerInstalledApp) to answer 'how many PCs have this app, and how many on
|
||||
the expected version'. Only entries carrying a curated appid appear; unlinked
|
||||
entries are skipped. FileVersion detection supplies the expected-version target;
|
||||
every other detection method yields a null target (no meaningful match). When
|
||||
the optional computers plugin is absent, counts degrade to null gracefully.
|
||||
"""
|
||||
|
||||
from shopdb.core.models import Asset, AssetType, Application
|
||||
|
||||
from plugins.geenforce import service
|
||||
|
||||
|
||||
def _seed_app(db, appname):
|
||||
app_row = Application(appname=appname)
|
||||
db.session.add(app_row)
|
||||
db.session.commit()
|
||||
return app_row.appid
|
||||
|
||||
|
||||
def _seed_pc_with_app(db, hostname, appid, installedversion, isactive=True):
|
||||
"""Create a Computer + one ComputerInstalledApp row for it."""
|
||||
from plugins.computers.models import Computer, ComputerInstalledApp
|
||||
atype = AssetType.query.filter_by(assettype='computer').first()
|
||||
if not atype:
|
||||
atype = AssetType(assettype='computer')
|
||||
db.session.add(atype)
|
||||
db.session.flush()
|
||||
asset = Asset(assetnumber=f'PC-{hostname}', assettypeid=atype.assettypeid)
|
||||
db.session.add(asset)
|
||||
db.session.flush()
|
||||
comp = Computer(assetid=asset.assetid, hostname=hostname)
|
||||
db.session.add(comp)
|
||||
db.session.flush()
|
||||
link = ComputerInstalledApp(computerid=comp.computerid, appid=appid,
|
||||
installedversion=installedversion,
|
||||
isactive=isactive)
|
||||
db.session.add(link)
|
||||
db.session.commit()
|
||||
return comp
|
||||
|
||||
|
||||
def _create_scope(client, auth_headers, name='gea-shopfloor-cmm'):
|
||||
resp = client.post('/api/geenforce/scopes',
|
||||
json={'scopename': name, 'phase': 'runtime'},
|
||||
headers=auth_headers)
|
||||
assert resp.status_code == 201, resp.get_json()
|
||||
return resp.get_json()['data']['scopeid']
|
||||
|
||||
|
||||
def _add_entry(client, auth_headers, scopeid, entry):
|
||||
resp = client.post(f'/api/geenforce/scopes/{scopeid}/entries',
|
||||
json=entry, headers=auth_headers)
|
||||
assert resp.status_code == 201, resp.get_json()
|
||||
return resp.get_json()['data']['entryid']
|
||||
|
||||
|
||||
def test_compliance_only_lists_app_linked_entries(client, db, auth_headers):
|
||||
appid = _seed_app(db, 'PC-DMIS')
|
||||
scopeid = _create_scope(client, auth_headers)
|
||||
_add_entry(client, auth_headers, scopeid,
|
||||
{'Name': 'PC-DMIS 2019', 'Type': 'MSI', 'appid': appid,
|
||||
'DetectionMethod': 'FileVersion', 'DetectionPath': 'C:\\p.exe',
|
||||
'DetectionValue': '2019 R1'})
|
||||
# An unlinked entry (no appid) must NOT appear.
|
||||
_add_entry(client, auth_headers, scopeid,
|
||||
{'Name': 'Unlinked', 'Type': 'MSI', 'Installer': 'apps/x.msi'})
|
||||
|
||||
data = client.get(f'/api/geenforce/scopes/{scopeid}/compliance',
|
||||
headers=auth_headers).get_json()['data']
|
||||
assert data['computersplugin'] is True
|
||||
assert [r['entryname'] for r in data['rows']] == ['PC-DMIS 2019']
|
||||
row = data['rows'][0]
|
||||
assert row['appid'] == appid
|
||||
assert row['appname'] == 'PC-DMIS'
|
||||
assert row['expectedversion'] == '2019 R1'
|
||||
|
||||
|
||||
def test_compliance_counts_active_installs_and_version_match(client, db,
|
||||
auth_headers):
|
||||
appid = _seed_app(db, 'PC-DMIS')
|
||||
# Two PCs on the expected version, one on a different version, one inactive.
|
||||
_seed_pc_with_app(db, 'CMM01', appid, '2019 R1')
|
||||
_seed_pc_with_app(db, 'CMM02', appid, '2019 R1')
|
||||
_seed_pc_with_app(db, 'CMM03', appid, '2016 R2')
|
||||
_seed_pc_with_app(db, 'CMM04', appid, '2019 R1', isactive=False)
|
||||
|
||||
scopeid = _create_scope(client, auth_headers)
|
||||
_add_entry(client, auth_headers, scopeid,
|
||||
{'Name': 'PC-DMIS 2019', 'Type': 'MSI', 'appid': appid,
|
||||
'DetectionMethod': 'FileVersion', 'DetectionPath': 'C:\\p.exe',
|
||||
'DetectionValue': '2019 R1'})
|
||||
|
||||
row = client.get(f'/api/geenforce/scopes/{scopeid}/compliance',
|
||||
headers=auth_headers).get_json()['data']['rows'][0]
|
||||
# Inactive row excluded: 3 active installs, 2 on the expected version.
|
||||
assert row['installedcount'] == 3
|
||||
assert row['versionmatchcount'] == 2
|
||||
assert '2 of 3' in row['coveragenote']
|
||||
|
||||
|
||||
def test_compliance_non_fileversion_has_null_version_target(client, db,
|
||||
auth_headers):
|
||||
appid = _seed_app(db, 'eDNC')
|
||||
_seed_pc_with_app(db, 'DNC01', appid, '5.0')
|
||||
scopeid = _create_scope(client, auth_headers)
|
||||
# Registry detection is not a version string -> no expected-version target.
|
||||
_add_entry(client, auth_headers, scopeid,
|
||||
{'Name': 'eDNC', 'Type': 'MSI', 'appid': appid,
|
||||
'DetectionMethod': 'Registry',
|
||||
'DetectionPath': 'HKLM\\Software\\eDNC'})
|
||||
|
||||
row = client.get(f'/api/geenforce/scopes/{scopeid}/compliance',
|
||||
headers=auth_headers).get_json()['data']['rows'][0]
|
||||
assert row['expectedversion'] is None
|
||||
assert row['installedcount'] == 1
|
||||
assert row['versionmatchcount'] is None
|
||||
assert 'no version target' in row['coveragenote']
|
||||
|
||||
|
||||
def test_compliance_not_installed_anywhere(client, db, auth_headers):
|
||||
appid = _seed_app(db, 'Orphan')
|
||||
scopeid = _create_scope(client, auth_headers)
|
||||
_add_entry(client, auth_headers, scopeid,
|
||||
{'Name': 'Orphan', 'Type': 'MSI', 'appid': appid,
|
||||
'DetectionMethod': 'FileVersion', 'DetectionPath': 'C:\\o.exe',
|
||||
'DetectionValue': '1.0'})
|
||||
|
||||
row = client.get(f'/api/geenforce/scopes/{scopeid}/compliance',
|
||||
headers=auth_headers).get_json()['data']['rows'][0]
|
||||
assert row['installedcount'] == 0
|
||||
assert row['versionmatchcount'] == 0
|
||||
assert row['coveragenote'] == 'not installed on any collected PC'
|
||||
|
||||
|
||||
def test_compliance_scope_not_found(client, db, auth_headers):
|
||||
resp = client.get('/api/geenforce/scopes/999999/compliance',
|
||||
headers=auth_headers)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_compliance_requires_manage_permission(client, db, member_headers):
|
||||
resp = client.get('/api/geenforce/scopes/1/compliance',
|
||||
headers=member_headers)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_compliance_graceful_without_computers_plugin(client, db, auth_headers,
|
||||
monkeypatch):
|
||||
appid = _seed_app(db, 'PC-DMIS')
|
||||
scopeid = _create_scope(client, auth_headers)
|
||||
_add_entry(client, auth_headers, scopeid,
|
||||
{'Name': 'PC-DMIS 2019', 'Type': 'MSI', 'appid': appid,
|
||||
'DetectionMethod': 'FileVersion', 'DetectionPath': 'C:\\p.exe',
|
||||
'DetectionValue': '2019 R1'})
|
||||
monkeypatch.setattr(service, '_installed_app_model', lambda: None)
|
||||
|
||||
data = client.get(f'/api/geenforce/scopes/{scopeid}/compliance',
|
||||
headers=auth_headers).get_json()['data']
|
||||
assert data['computersplugin'] is False
|
||||
row = data['rows'][0]
|
||||
assert row['installedcount'] is None
|
||||
assert row['versionmatchcount'] is None
|
||||
assert 'computers plugin not installed' in row['coveragenote']
|
||||
278
tests/test_plugins/test_geenforce_crud.py
Normal file
278
tests/test_plugins/test_geenforce_crud.py
Normal file
@@ -0,0 +1,278 @@
|
||||
"""GE-Enforce P2 admin CRUD: scopes, entries, reorder, simulate, publish cycle.
|
||||
|
||||
All admin endpoints are JWT + geenforce.manage/publish gated (the admin user
|
||||
holds both). Entry payloads use the manifest Applications[] shape.
|
||||
"""
|
||||
|
||||
|
||||
def _create_scope(client, auth_headers, name='gea-shopfloor-test'):
|
||||
resp = client.post('/api/geenforce/scopes',
|
||||
json={'scopename': name, 'phase': 'runtime',
|
||||
'description': 'test scope'},
|
||||
headers=auth_headers)
|
||||
assert resp.status_code == 201, resp.get_json()
|
||||
return resp.get_json()['data']['scopeid']
|
||||
|
||||
|
||||
def _add_entry(client, auth_headers, scopeid, entry):
|
||||
resp = client.post(f'/api/geenforce/scopes/{scopeid}/entries',
|
||||
json=entry, headers=auth_headers)
|
||||
assert resp.status_code == 201, resp.get_json()
|
||||
return resp.get_json()['data']['entryid']
|
||||
|
||||
|
||||
def test_scope_crud(client, db, auth_headers):
|
||||
scopeid = _create_scope(client, auth_headers)
|
||||
|
||||
got = client.get(f'/api/geenforce/scopes/{scopeid}', headers=auth_headers)
|
||||
assert got.status_code == 200
|
||||
assert got.get_json()['data']['scopename'] == 'gea-shopfloor-test'
|
||||
|
||||
upd = client.put(f'/api/geenforce/scopes/{scopeid}',
|
||||
json={'description': 'updated', 'computertypeid': 3},
|
||||
headers=auth_headers)
|
||||
assert upd.get_json()['data']['computertypeid'] == 3
|
||||
|
||||
dele = client.delete(f'/api/geenforce/scopes/{scopeid}', headers=auth_headers)
|
||||
assert dele.status_code == 200
|
||||
assert client.get(f'/api/geenforce/scopes/{scopeid}',
|
||||
headers=auth_headers).status_code == 404
|
||||
|
||||
|
||||
def test_duplicate_scope_rejected(client, db, auth_headers):
|
||||
_create_scope(client, auth_headers, 'gea-shopfloor-dup')
|
||||
resp = client.post('/api/geenforce/scopes',
|
||||
json={'scopename': 'gea-shopfloor-dup', 'phase': 'runtime'},
|
||||
headers=auth_headers)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_entry_crud_and_reorder(client, db, auth_headers):
|
||||
scopeid = _create_scope(client, auth_headers)
|
||||
a = _add_entry(client, auth_headers, scopeid,
|
||||
{'Name': 'Alpha', 'Type': 'MSI', 'Installer': 'apps/a.msi'})
|
||||
b = _add_entry(client, auth_headers, scopeid,
|
||||
{'Name': 'Beta', 'Type': 'PS1', 'Script': 'scripts/b.ps1'})
|
||||
|
||||
# Update Alpha: change type + add detection.
|
||||
upd = client.put(f'/api/geenforce/entries/{a}',
|
||||
json={'Name': 'Alpha', 'Type': 'EXE', 'Installer': 'apps/a.exe',
|
||||
'DetectionMethod': 'FileVersion',
|
||||
'DetectionPath': 'C:\\a.exe', 'DetectionValue': '1.0'},
|
||||
headers=auth_headers)
|
||||
assert upd.status_code == 200
|
||||
assert upd.get_json()['data']['Type'] == 'EXE'
|
||||
assert upd.get_json()['data']['DetectionMethod'] == 'FileVersion'
|
||||
|
||||
# Reorder: Beta first.
|
||||
reo = client.put(f'/api/geenforce/scopes/{scopeid}/entries/reorder',
|
||||
json={'order': [b, a]}, headers=auth_headers)
|
||||
assert reo.status_code == 200
|
||||
entries = client.get(f'/api/geenforce/scopes/{scopeid}',
|
||||
headers=auth_headers).get_json()['data']['entries']
|
||||
assert [e['Name'] for e in entries] == ['Beta', 'Alpha']
|
||||
|
||||
# Delete Beta.
|
||||
assert client.delete(f'/api/geenforce/entries/{b}',
|
||||
headers=auth_headers).status_code == 200
|
||||
entries = client.get(f'/api/geenforce/scopes/{scopeid}',
|
||||
headers=auth_headers).get_json()['data']['entries']
|
||||
assert [e['Name'] for e in entries] == ['Alpha']
|
||||
|
||||
|
||||
def test_entry_edit_preserves_full_fidelity(client, db, auth_headers):
|
||||
"""Editing an entry with the complete payload keeps InUseCheck ExePath +
|
||||
timeout, LogFile, and preinstall flags (the UI now sends all of them)."""
|
||||
scopeid = _create_scope(client, auth_headers)
|
||||
rich = {
|
||||
'Name': 'PC-DMIS', 'Type': 'MSI', 'Installer': 'apps/pcdmis.msi',
|
||||
'LogFile': 'C:\\Logs\\pcdmis.log',
|
||||
'PreEnrollment': True, 'PCTypesStrict': True,
|
||||
'InUseCheck': {'Behavior': 'CloseAndReopen', 'Processes': [
|
||||
{'Name': 'PCDLRN', 'ExePath': 'C:\\PCDLRN.exe',
|
||||
'GracefulCloseTimeoutSec': 15}]},
|
||||
}
|
||||
entryid = _add_entry(client, auth_headers, scopeid, rich)
|
||||
|
||||
# Re-send the same full payload (what the editor now does) and confirm
|
||||
# nothing is stripped.
|
||||
upd = client.put(f'/api/geenforce/entries/{entryid}', json=rich,
|
||||
headers=auth_headers)
|
||||
assert upd.status_code == 200
|
||||
entry = upd.get_json()['data']
|
||||
assert entry['LogFile'] == 'C:\\Logs\\pcdmis.log'
|
||||
assert entry['PreEnrollment'] is True
|
||||
assert entry['PCTypesStrict'] is True
|
||||
proc = entry['InUseCheck']['Processes'][0]
|
||||
assert proc['ExePath'] == 'C:\\PCDLRN.exe'
|
||||
assert proc['GracefulCloseTimeoutSec'] == 15
|
||||
|
||||
|
||||
def test_entry_curated_app_link(client, db, auth_headers):
|
||||
"""An entry can be linked to an existing Application; the link is shopdb
|
||||
metadata and must NOT leak into the manifest JSON (parity/enforcement)."""
|
||||
from shopdb.core.models import Application
|
||||
app_row = Application(appname='eDNC (tracked)')
|
||||
db.session.add(app_row)
|
||||
db.session.commit()
|
||||
appid = app_row.appid
|
||||
|
||||
scopeid = _create_scope(client, auth_headers)
|
||||
created = client.post(f'/api/geenforce/scopes/{scopeid}/entries',
|
||||
json={'Name': 'eDNC install', 'Type': 'MSI',
|
||||
'appid': appid}, headers=auth_headers)
|
||||
assert created.status_code == 201, created.get_json()
|
||||
data = created.get_json()['data']
|
||||
assert data['appid'] == appid
|
||||
assert data['appname'] == 'eDNC (tracked)'
|
||||
|
||||
# the link is NOT in the rendered manifest (would break the engine contract)
|
||||
manifest = client.get(f'/api/geenforce/scopes/{scopeid}/preview',
|
||||
headers=auth_headers).get_json()['data']['manifest']
|
||||
entry_json = manifest['Applications'][0]
|
||||
assert 'appid' not in entry_json and 'appname' not in entry_json
|
||||
|
||||
# unlink
|
||||
updated = client.put(f"/api/geenforce/entries/{data['entryid']}",
|
||||
json={'Name': 'eDNC install', 'Type': 'MSI', 'appid': None},
|
||||
headers=auth_headers)
|
||||
assert updated.get_json()['data']['appid'] is None
|
||||
|
||||
# picker lists the app
|
||||
picker = client.get('/api/geenforce/applications',
|
||||
headers=auth_headers).get_json()['data']
|
||||
assert any(a['appname'] == 'eDNC (tracked)' for a in picker)
|
||||
|
||||
|
||||
def test_entry_nonnumeric_appid_ignored(client, db, auth_headers):
|
||||
"""A non-numeric appid must be ignored (link stays null), not 500. Matches
|
||||
the _apply_app_link docstring contract."""
|
||||
scopeid = _create_scope(client, auth_headers)
|
||||
created = client.post(f'/api/geenforce/scopes/{scopeid}/entries',
|
||||
json={'Name': 'x', 'Type': 'MSI', 'appid': 'notanumber'},
|
||||
headers=auth_headers)
|
||||
assert created.status_code == 201, created.get_json()
|
||||
assert created.get_json()['data']['appid'] is None
|
||||
|
||||
|
||||
def test_invalid_entry_type_rejected(client, db, auth_headers):
|
||||
scopeid = _create_scope(client, auth_headers)
|
||||
resp = client.post(f'/api/geenforce/scopes/{scopeid}/entries',
|
||||
json={'Name': 'Bad', 'Type': 'NOTATYPE'},
|
||||
headers=auth_headers)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_reorder_rejects_foreign_ids(client, db, auth_headers):
|
||||
scopeid = _create_scope(client, auth_headers)
|
||||
a = _add_entry(client, auth_headers, scopeid,
|
||||
{'Name': 'Alpha', 'Type': 'MSI'})
|
||||
resp = client.put(f'/api/geenforce/scopes/{scopeid}/entries/reorder',
|
||||
json={'order': [a, 9999]}, headers=auth_headers)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_simulate_cmm_version_gate(client, db, auth_headers):
|
||||
scopeid = _create_scope(client, auth_headers, 'gea-shopfloor-cmm')
|
||||
_add_entry(client, auth_headers, scopeid,
|
||||
{'Name': 'CLM (untagged)', 'Type': 'MSI', 'Installer': 'apps/clm.msi'})
|
||||
_add_entry(client, auth_headers, scopeid,
|
||||
{'Name': 'PC-DMIS 2016', 'Type': 'MSI', 'Installer': 'apps/16.msi',
|
||||
'_CmmVersion': '2016'})
|
||||
_add_entry(client, auth_headers, scopeid,
|
||||
{'Name': 'PC-DMIS 2019', 'Type': 'MSI', 'Installer': 'apps/19.msi',
|
||||
'_CmmVersion': '2019'})
|
||||
|
||||
sim = client.get(f'/api/geenforce/scopes/{scopeid}/simulate?cmmversion=2019',
|
||||
headers=auth_headers)
|
||||
assert sim.status_code == 200
|
||||
data = sim.get_json()['data']
|
||||
assert data['applied'] == ['CLM (untagged)', 'PC-DMIS 2019']
|
||||
filtered = {f['name']: f['filteredby'] for f in data['filtered']}
|
||||
assert filtered['PC-DMIS 2016'] == ['_CmmVersion']
|
||||
|
||||
|
||||
def test_duplicate_entry_name_is_400_not_500(client, db, auth_headers):
|
||||
scopeid = _create_scope(client, auth_headers)
|
||||
_add_entry(client, auth_headers, scopeid, {'Name': 'Alpha', 'Type': 'MSI'})
|
||||
dup = client.post(f'/api/geenforce/scopes/{scopeid}/entries',
|
||||
json={'Name': 'Alpha', 'Type': 'PS1'}, headers=auth_headers)
|
||||
assert dup.status_code == 400
|
||||
|
||||
|
||||
def test_simulate_pctypesstrict_disables_alias(client, db, auth_headers):
|
||||
"""A collections-only strict entry must NOT match a nocollections PC via the
|
||||
shared Standard alias group. PCTypesStrict is preinstall-only, so the scope
|
||||
must be the preinstall phase (the runtime engine ignores strict)."""
|
||||
resp = client.post('/api/geenforce/scopes',
|
||||
json={'scopename': 'preinstall', 'phase': 'preinstall'},
|
||||
headers=auth_headers)
|
||||
assert resp.status_code == 201, resp.get_json()
|
||||
scopeid = resp.get_json()['data']['scopeid']
|
||||
_add_entry(client, auth_headers, scopeid,
|
||||
{'Name': 'UDC (strict)', 'Type': 'EXE', 'Installer': 'apps/udc.exe',
|
||||
'PCTypes': ['gea-shopfloor-collections'], 'PCTypesStrict': True})
|
||||
_add_entry(client, auth_headers, scopeid,
|
||||
{'Name': 'UDC (loose)', 'Type': 'EXE', 'Installer': 'apps/udc.exe',
|
||||
'PCTypes': ['gea-shopfloor-collections']})
|
||||
|
||||
sim = client.get(
|
||||
f'/api/geenforce/scopes/{scopeid}/simulate?pctype=gea-shopfloor-nocollections',
|
||||
headers=auth_headers).get_json()['data']
|
||||
# loose entry matches via the Standard alias group; strict entry does not.
|
||||
assert sim['applied'] == ['UDC (loose)']
|
||||
filtered = {f['name']: f['filteredby'] for f in sim['filtered']}
|
||||
assert filtered['UDC (strict)'] == ['PCTypes']
|
||||
|
||||
|
||||
def test_simulate_machine_number_gate(client, db, auth_headers):
|
||||
scopeid = _create_scope(client, auth_headers, 'gea-shopfloor-collections')
|
||||
_add_entry(client, auth_headers, scopeid,
|
||||
{'Name': 'Okuma cred', 'Type': 'PS1', 'Script': 'scripts/o.ps1',
|
||||
'TargetMachineNumbers': ['3201', '3202']})
|
||||
_add_entry(client, auth_headers, scopeid,
|
||||
{'Name': 'Everyone', 'Type': 'PS1', 'Script': 'scripts/e.ps1'})
|
||||
|
||||
match = client.get(
|
||||
f'/api/geenforce/scopes/{scopeid}/simulate?machinenumber=3201',
|
||||
headers=auth_headers).get_json()['data']
|
||||
assert set(match['applied']) == {'Okuma cred', 'Everyone'}
|
||||
|
||||
nomatch = client.get(
|
||||
f'/api/geenforce/scopes/{scopeid}/simulate?machinenumber=9999',
|
||||
headers=auth_headers).get_json()['data']
|
||||
assert nomatch['applied'] == ['Everyone']
|
||||
|
||||
|
||||
def test_publish_versions_rollback(client, db, auth_headers):
|
||||
scopeid = _create_scope(client, auth_headers, 'gea-shopfloor-cmm')
|
||||
_add_entry(client, auth_headers, scopeid, {'Name': 'Alpha', 'Type': 'MSI'})
|
||||
|
||||
pub1 = client.post(f'/api/geenforce/scopes/{scopeid}/publish',
|
||||
json={'notes': 'v1'}, headers=auth_headers)
|
||||
assert pub1.status_code == 201
|
||||
assert pub1.get_json()['data']['versionnumber'] == 1
|
||||
|
||||
_add_entry(client, auth_headers, scopeid, {'Name': 'Beta', 'Type': 'PS1'})
|
||||
pub2 = client.post(f'/api/geenforce/scopes/{scopeid}/publish',
|
||||
json={'notes': 'v2'}, headers=auth_headers)
|
||||
assert pub2.get_json()['data']['versionnumber'] == 2
|
||||
|
||||
versions = client.get(f'/api/geenforce/scopes/{scopeid}/versions',
|
||||
headers=auth_headers).get_json()['data']
|
||||
assert [v['versionnumber'] for v in versions] == [2, 1]
|
||||
assert versions[0]['iscurrent'] is True
|
||||
|
||||
# v2 manifest has both entries.
|
||||
v2doc = client.get(f'/api/geenforce/scopes/{scopeid}/versions/2',
|
||||
headers=auth_headers).get_json()['data']['manifest']
|
||||
assert [e['Name'] for e in v2doc['Applications']] == ['Alpha', 'Beta']
|
||||
|
||||
# Rollback to v1.
|
||||
rb = client.post(f'/api/geenforce/scopes/{scopeid}/rollback',
|
||||
json={'versionnumber': 1}, headers=auth_headers)
|
||||
assert rb.status_code == 200
|
||||
versions = client.get(f'/api/geenforce/scopes/{scopeid}/versions',
|
||||
headers=auth_headers).get_json()['data']
|
||||
current = next(v for v in versions if v['iscurrent'])
|
||||
assert current['versionnumber'] == 1
|
||||
134
tests/test_plugins/test_geenforce_ddl_parity.py
Normal file
134
tests/test_plugins/test_geenforce_ddl_parity.py
Normal file
@@ -0,0 +1,134 @@
|
||||
"""GE-Enforce DDL parity gate: models match the hand-written Alembic baseline.
|
||||
|
||||
The geenforce baseline (0001_geenforce_baseline.py) actually CREATES the plugin
|
||||
tables (ADR-008 per-plugin chain), and it is hand-written - the models and the
|
||||
migration can drift silently (a column added to a model but not the migration
|
||||
ships a schema that upgrade() never builds, and vice versa). This guard builds
|
||||
the schema two ways and diffs them column-by-column:
|
||||
|
||||
1. the SQLAlchemy models via metadata.create_all (desired shape), and
|
||||
2. the migration's upgrade() run against a fresh engine (shipped shape).
|
||||
|
||||
Both go through the same SQLite dialect, so the reflected column types render
|
||||
identically and the comparison stays low-flake. A drift (missing column, changed
|
||||
type, changed nullability) fails here instead of on a real deploy insert.
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import create_engine, inspect
|
||||
from alembic.migration import MigrationContext
|
||||
from alembic.operations import Operations
|
||||
|
||||
|
||||
_BASELINE = (Path(__file__).resolve().parent.parent.parent / 'plugins' /
|
||||
'geenforce' / 'migrations' / 'versions' /
|
||||
'0001_geenforce_baseline.py')
|
||||
|
||||
|
||||
def _geenforce_models():
|
||||
from plugins.geenforce.models import manifest
|
||||
return [
|
||||
manifest.ManifestScope,
|
||||
manifest.ManifestEntry,
|
||||
manifest.ManifestEntryPcType,
|
||||
manifest.ManifestEntryHostname,
|
||||
manifest.ManifestEntryMachineNumber,
|
||||
manifest.ManifestInUseCheck,
|
||||
manifest.ManifestInUseCheckProcess,
|
||||
manifest.ManifestPublishedVersion,
|
||||
manifest.ManifestPayload,
|
||||
manifest.ManifestEnforcementReport,
|
||||
manifest.ManifestEnforcementResult,
|
||||
manifest.PcTypeAlias,
|
||||
]
|
||||
|
||||
|
||||
def _model_schema(db, models):
|
||||
"""Build the geenforce tables from the models; return an inspector."""
|
||||
engine = create_engine('sqlite://')
|
||||
db.metadata.create_all(engine, tables=[model.__table__ for model in models])
|
||||
return inspect(engine)
|
||||
|
||||
|
||||
def _migration_schema():
|
||||
"""Run the baseline migration's upgrade() on a fresh engine; return columns.
|
||||
|
||||
Returns {tablename: {columnname: reflected-column-dict}}. Reflection happens
|
||||
while the connection is open, so we materialize the dicts before it closes.
|
||||
"""
|
||||
spec = importlib.util.spec_from_file_location('geenforce_baseline',
|
||||
str(_BASELINE))
|
||||
migration = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(migration)
|
||||
|
||||
engine = create_engine('sqlite://')
|
||||
schema = {}
|
||||
with engine.connect() as connection:
|
||||
operations = Operations(MigrationContext.configure(connection))
|
||||
operations._install_proxy()
|
||||
try:
|
||||
migration.upgrade()
|
||||
finally:
|
||||
operations._remove_proxy()
|
||||
connection.commit()
|
||||
inspector = inspect(connection)
|
||||
for table in inspector.get_table_names():
|
||||
schema[table] = {column['name']: column
|
||||
for column in inspector.get_columns(table)}
|
||||
return schema
|
||||
|
||||
|
||||
def _colkey(column):
|
||||
"""Normalize a reflected column to the facets we gate on."""
|
||||
return (str(column['type']), bool(column['nullable']))
|
||||
|
||||
|
||||
def test_models_match_migration_baseline(app, db):
|
||||
"""Every geenforce model table+column matches what the migration builds."""
|
||||
with app.app_context():
|
||||
models = _geenforce_models()
|
||||
model_inspector = _model_schema(db, models)
|
||||
migration_schema = _migration_schema()
|
||||
|
||||
drift = []
|
||||
for model in models:
|
||||
table = model.__tablename__
|
||||
if table not in migration_schema:
|
||||
drift.append(f'{table}: table missing from migration')
|
||||
continue
|
||||
modelcols = {column['name']: column
|
||||
for column in model_inspector.get_columns(table)}
|
||||
migrationcols = migration_schema[table]
|
||||
|
||||
missing = set(modelcols) - set(migrationcols)
|
||||
extra = set(migrationcols) - set(modelcols)
|
||||
for name in sorted(missing):
|
||||
drift.append(f'{table}.{name}: in model, not in migration')
|
||||
for name in sorted(extra):
|
||||
drift.append(f'{table}.{name}: in migration, not in model')
|
||||
for name in sorted(set(modelcols) & set(migrationcols)):
|
||||
if _colkey(modelcols[name]) != _colkey(migrationcols[name]):
|
||||
drift.append(
|
||||
f'{table}.{name}: model {_colkey(modelcols[name])} != '
|
||||
f'migration {_colkey(migrationcols[name])}')
|
||||
|
||||
assert not drift, 'model/migration DDL drift:\n' + '\n'.join(drift)
|
||||
|
||||
|
||||
def test_curated_and_payload_columns_present_both_sides(app, db):
|
||||
"""Spot-guard the two real-work columns: manifestentries.appid and
|
||||
manifestpayloads.payloadsha256 exist in BOTH the models and the migration
|
||||
(they were added for the app-link + inline-payload features)."""
|
||||
with app.app_context():
|
||||
models = _geenforce_models()
|
||||
model_inspector = _model_schema(db, models)
|
||||
migration_schema = _migration_schema()
|
||||
|
||||
for table, column in (('manifestentries', 'appid'),
|
||||
('manifestpayloads', 'payloadsha256')):
|
||||
modelcols = {c['name'] for c in model_inspector.get_columns(table)}
|
||||
assert column in modelcols, f'{table}.{column} missing from models'
|
||||
assert column in migration_schema[table], \
|
||||
f'{table}.{column} missing from migration'
|
||||
65
tests/test_plugins/test_geenforce_export.py
Normal file
65
tests/test_plugins/test_geenforce_export.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""GE-Enforce export-to-share: config the share root, publish, write to share.
|
||||
|
||||
This is the Milestone 1 loop - author + publish in shopdb, then export the
|
||||
published JSON to the on-share manifest with a history backup, engine untouched.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
|
||||
from plugins.geenforce import service
|
||||
|
||||
|
||||
def _scope_with_entry(app, name='gea-shopfloor-cmm'):
|
||||
with app.app_context():
|
||||
service.replace_scope_draft(name, 'runtime', {
|
||||
'Version': '2.6',
|
||||
'Applications': [{'Name': 'Alpha', 'Type': 'MSI',
|
||||
'Installer': 'apps/a.msi'}]})
|
||||
service.publish_scope(name, 'runtime', notes='v1')
|
||||
scope = service.ManifestScope.query.filter_by(scopename=name).first()
|
||||
scopeid = scope.scopeid
|
||||
service.db.session.commit()
|
||||
return scopeid
|
||||
|
||||
|
||||
def test_export_requires_config(client, db, app, auth_headers):
|
||||
scopeid = _scope_with_entry(app)
|
||||
resp = client.post(f'/api/geenforce/scopes/{scopeid}/export-share',
|
||||
headers=auth_headers)
|
||||
assert resp.status_code == 400 # no share root configured
|
||||
|
||||
|
||||
def test_config_roundtrip_and_export(client, db, app, auth_headers, tmp_path):
|
||||
scopeid = _scope_with_entry(app)
|
||||
|
||||
# Configure the share root.
|
||||
put = client.put('/api/geenforce/config',
|
||||
json={'shareroot': str(tmp_path)}, headers=auth_headers)
|
||||
assert put.status_code == 200
|
||||
got = client.get('/api/geenforce/config', headers=auth_headers)
|
||||
assert got.get_json()['data']['shareroot'] == str(tmp_path)
|
||||
|
||||
# Export writes the published JSON to <shareroot>/<scope>/manifest.json.
|
||||
resp = client.post(f'/api/geenforce/scopes/{scopeid}/export-share',
|
||||
headers=auth_headers)
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
path = resp.get_json()['data']['path']
|
||||
assert os.path.isfile(path)
|
||||
written = json.load(open(path))
|
||||
assert [e['Name'] for e in written['Applications']] == ['Alpha']
|
||||
|
||||
|
||||
def test_export_backs_up_existing(client, db, app, auth_headers, tmp_path):
|
||||
scopeid = _scope_with_entry(app)
|
||||
client.put('/api/geenforce/config', json={'shareroot': str(tmp_path)},
|
||||
headers=auth_headers)
|
||||
# First export creates the file.
|
||||
client.post(f'/api/geenforce/scopes/{scopeid}/export-share',
|
||||
headers=auth_headers)
|
||||
# Second export backs the old file into _meta/history.
|
||||
client.post(f'/api/geenforce/scopes/{scopeid}/export-share',
|
||||
headers=auth_headers)
|
||||
historydir = tmp_path / '_meta' / 'history'
|
||||
assert historydir.is_dir()
|
||||
assert any(historydir.iterdir())
|
||||
152
tests/test_plugins/test_geenforce_manifest.py
Normal file
152
tests/test_plugins/test_geenforce_manifest.py
Normal file
@@ -0,0 +1,152 @@
|
||||
"""GE-Enforce first slice: import a scope, publish it, serve it to a client.
|
||||
|
||||
Exercises the vertical the plan calls the first slice (via a small synthetic
|
||||
scope rather than the real gea-shopfloor-cmm): draft import -> publish ->
|
||||
GET /api/geenforce/manifest with a geenforce.fetch service token; ETag/304;
|
||||
draft edits never change the served bytes; publish + rollback change them.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from plugins.geenforce.models import ManifestScope
|
||||
from plugins.geenforce import service
|
||||
|
||||
|
||||
SCOPE = {
|
||||
'Version': '2.6',
|
||||
'Applications': [
|
||||
{'Name': 'Alpha', 'Type': 'MSI', 'Installer': 'apps/alpha.msi',
|
||||
'DetectionMethod': 'Registry', 'DetectionPath': 'HKLM:\\SOFTWARE\\Alpha'},
|
||||
{'Name': 'Beta', 'Type': 'PS1', 'Script': 'scripts/beta.ps1',
|
||||
'DetectionMethod': 'Always'},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _seed_and_publish(app, scopename='gea-shopfloor-cmm', manifest=None):
|
||||
with app.app_context():
|
||||
service.replace_scope_draft(scopename, 'runtime', manifest or SCOPE)
|
||||
service.publish_scope(scopename, 'runtime', notes='initial')
|
||||
service.db.session.commit()
|
||||
|
||||
|
||||
def _mint_fetch_token(client, auth_headers):
|
||||
resp = client.post('/api/apitokens',
|
||||
json={'name': 'geenforce svc', 'scopes': ['geenforce.fetch']},
|
||||
headers=auth_headers)
|
||||
assert resp.status_code == 201, resp.get_json()
|
||||
return resp.get_json()['data']['secret']
|
||||
|
||||
|
||||
def test_import_publish_serve(client, db, app, auth_headers):
|
||||
_seed_and_publish(app)
|
||||
secret = _mint_fetch_token(client, auth_headers)
|
||||
|
||||
resp = client.get('/api/geenforce/manifest?pctype=gea-shopfloor-cmm',
|
||||
headers={'X-API-Key': secret})
|
||||
assert resp.status_code == 200, resp.data
|
||||
body = json.loads(resp.data)
|
||||
assert [e['Name'] for e in body['Applications']] == ['Alpha', 'Beta']
|
||||
assert resp.headers.get('ETag')
|
||||
assert resp.headers.get('X-Manifest-Version') == '1'
|
||||
|
||||
|
||||
def test_etag_304(client, db, app, auth_headers):
|
||||
_seed_and_publish(app)
|
||||
secret = _mint_fetch_token(client, auth_headers)
|
||||
first = client.get('/api/geenforce/manifest?pctype=gea-shopfloor-cmm',
|
||||
headers={'X-API-Key': secret})
|
||||
etag = first.headers['ETag']
|
||||
again = client.get('/api/geenforce/manifest?pctype=gea-shopfloor-cmm',
|
||||
headers={'X-API-Key': secret, 'If-None-Match': etag})
|
||||
assert again.status_code == 304
|
||||
|
||||
|
||||
def test_draft_edit_does_not_change_served_bytes(client, db, app, auth_headers):
|
||||
_seed_and_publish(app)
|
||||
secret = _mint_fetch_token(client, auth_headers)
|
||||
before = client.get('/api/geenforce/manifest?pctype=gea-shopfloor-cmm',
|
||||
headers={'X-API-Key': secret}).data
|
||||
|
||||
# Edit the DRAFT (add an entry) but do NOT publish.
|
||||
changed = {'Version': '2.6', 'Applications': SCOPE['Applications'] + [
|
||||
{'Name': 'Gamma', 'Type': 'PS1', 'Script': 'scripts/gamma.ps1'}]}
|
||||
with app.app_context():
|
||||
service.replace_scope_draft('gea-shopfloor-cmm', 'runtime', changed)
|
||||
service.db.session.commit()
|
||||
|
||||
after = client.get('/api/geenforce/manifest?pctype=gea-shopfloor-cmm',
|
||||
headers={'X-API-Key': secret}).data
|
||||
assert before == after # served bytes come from the published snapshot only
|
||||
|
||||
|
||||
def test_publish_then_rollback(client, db, app, auth_headers):
|
||||
_seed_and_publish(app)
|
||||
secret = _mint_fetch_token(client, auth_headers)
|
||||
v1 = client.get('/api/geenforce/manifest?pctype=gea-shopfloor-cmm',
|
||||
headers={'X-API-Key': secret}).data
|
||||
|
||||
changed = {'Version': '2.6', 'Applications': SCOPE['Applications'] + [
|
||||
{'Name': 'Gamma', 'Type': 'PS1', 'Script': 'scripts/gamma.ps1'}]}
|
||||
with app.app_context():
|
||||
service.replace_scope_draft('gea-shopfloor-cmm', 'runtime', changed)
|
||||
service.publish_scope('gea-shopfloor-cmm', 'runtime', notes='add gamma')
|
||||
service.db.session.commit()
|
||||
|
||||
v2 = client.get('/api/geenforce/manifest?pctype=gea-shopfloor-cmm',
|
||||
headers={'X-API-Key': secret})
|
||||
assert v2.headers['X-Manifest-Version'] == '2'
|
||||
assert b'Gamma' in v2.data
|
||||
|
||||
with app.app_context():
|
||||
service.rollback_scope('gea-shopfloor-cmm', 'runtime', 1)
|
||||
service.db.session.commit()
|
||||
|
||||
rolled = client.get('/api/geenforce/manifest?pctype=gea-shopfloor-cmm',
|
||||
headers={'X-API-Key': secret})
|
||||
assert rolled.data == v1 # byte-identical: published snapshots are frozen text
|
||||
|
||||
|
||||
def test_unauthenticated_rejected(client, db, app):
|
||||
_seed_and_publish(app)
|
||||
resp = client.get('/api/geenforce/manifest?pctype=gea-shopfloor-cmm')
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
def test_wrong_scope_rejected(client, db, app, auth_headers):
|
||||
_seed_and_publish(app)
|
||||
resp = client.post('/api/apitokens',
|
||||
json={'name': 'wrong', 'scopes': ['collector.ingest']},
|
||||
headers=auth_headers)
|
||||
secret = resp.get_json()['data']['secret']
|
||||
manifest = client.get('/api/geenforce/manifest?pctype=gea-shopfloor-cmm',
|
||||
headers={'X-API-Key': secret})
|
||||
assert manifest.status_code == 401
|
||||
|
||||
|
||||
def test_unpublished_scope_404(client, db, app, auth_headers):
|
||||
with app.app_context():
|
||||
service.replace_scope_draft('gea-shopfloor-keyence', 'runtime', SCOPE)
|
||||
service.db.session.commit() # draft only, never published
|
||||
secret = _mint_fetch_token(client, auth_headers)
|
||||
resp = client.get('/api/geenforce/manifest?pctype=gea-shopfloor-keyence',
|
||||
headers={'X-API-Key': secret})
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_admin_list_and_preview(client, db, app, auth_headers):
|
||||
_seed_and_publish(app)
|
||||
listing = client.get('/api/geenforce/scopes', headers=auth_headers)
|
||||
assert listing.status_code == 200
|
||||
scopes = listing.get_json()['data']
|
||||
cmm = next(s for s in scopes if s['scopename'] == 'gea-shopfloor-cmm')
|
||||
assert cmm['entrycount'] == 2
|
||||
assert cmm['publishedversion'] == 1
|
||||
|
||||
preview = client.get(f"/api/geenforce/scopes/{cmm['scopeid']}/preview",
|
||||
headers=auth_headers)
|
||||
assert preview.status_code == 200
|
||||
assert [e['Name'] for e in preview.get_json()['data']['manifest']['Applications']] \
|
||||
== ['Alpha', 'Beta']
|
||||
125
tests/test_plugins/test_geenforce_payload.py
Normal file
125
tests/test_plugins/test_geenforce_payload.py
Normal file
@@ -0,0 +1,125 @@
|
||||
"""GE-Enforce inline payloads: upload small scripts/configs into shopdb.
|
||||
|
||||
An entry can carry one inline payload (<= 1 MB) stored as ManifestPayload bytes.
|
||||
Upload computes a sha256, sets the entry's payloadsource='inline' + payloadref,
|
||||
and enforces one payload row per entry (re-upload replaces). GET returns the
|
||||
raw bytes. Upload needs geenforce.publish; GET needs geenforce.manage. appid and
|
||||
the payload metadata are shopdb-only and never enter the served manifest JSON.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import io
|
||||
|
||||
from plugins.geenforce.models import ManifestPayload
|
||||
|
||||
|
||||
def _create_scope(client, auth_headers, name='gea-shopfloor-cmm'):
|
||||
resp = client.post('/api/geenforce/scopes',
|
||||
json={'scopename': name, 'phase': 'runtime'},
|
||||
headers=auth_headers)
|
||||
assert resp.status_code == 201, resp.get_json()
|
||||
return resp.get_json()['data']['scopeid']
|
||||
|
||||
|
||||
def _add_entry(client, auth_headers, scopeid, name='eDNC install'):
|
||||
resp = client.post(f'/api/geenforce/scopes/{scopeid}/entries',
|
||||
json={'Name': name, 'Type': 'PS1',
|
||||
'Script': 'scripts/x.ps1'},
|
||||
headers=auth_headers)
|
||||
assert resp.status_code == 201, resp.get_json()
|
||||
return resp.get_json()['data']['entryid']
|
||||
|
||||
|
||||
def _upload(client, headers, entryid, content, filename='config.reg',
|
||||
content_type='multipart/form-data'):
|
||||
return client.post(
|
||||
f'/api/geenforce/entries/{entryid}/payload',
|
||||
data={'file': (io.BytesIO(content), filename)},
|
||||
content_type=content_type, headers=headers)
|
||||
|
||||
|
||||
def test_upload_stores_payload_and_links_entry(client, db, auth_headers):
|
||||
scopeid = _create_scope(client, auth_headers)
|
||||
entryid = _add_entry(client, auth_headers, scopeid)
|
||||
content = b'Windows Registry Editor Version 5.00\r\n'
|
||||
|
||||
resp = _upload(client, auth_headers, entryid, content)
|
||||
assert resp.status_code == 201, resp.get_json()
|
||||
data = resp.get_json()['data']
|
||||
assert data['payloadsource'] == 'inline'
|
||||
assert data['payloadref'] == 'config.reg'
|
||||
assert data['payloadsha256'] == hashlib.sha256(content).hexdigest()
|
||||
assert data['haspayload'] is True
|
||||
|
||||
rows = ManifestPayload.query.filter_by(entryid=entryid).all()
|
||||
assert len(rows) == 1
|
||||
assert rows[0].payloadbytes == content
|
||||
|
||||
|
||||
def test_reupload_replaces_single_row(client, db, auth_headers):
|
||||
scopeid = _create_scope(client, auth_headers)
|
||||
entryid = _add_entry(client, auth_headers, scopeid)
|
||||
_upload(client, auth_headers, entryid, b'first')
|
||||
newcontent = b'second version'
|
||||
resp = _upload(client, auth_headers, entryid, newcontent)
|
||||
assert resp.status_code == 201
|
||||
|
||||
rows = ManifestPayload.query.filter_by(entryid=entryid).all()
|
||||
assert len(rows) == 1
|
||||
assert rows[0].payloadbytes == newcontent
|
||||
assert rows[0].payloadsha256 == hashlib.sha256(newcontent).hexdigest()
|
||||
|
||||
|
||||
def test_oversized_payload_rejected(client, db, auth_headers):
|
||||
scopeid = _create_scope(client, auth_headers)
|
||||
entryid = _add_entry(client, auth_headers, scopeid)
|
||||
toobig = b'x' * (1024 * 1024 + 1)
|
||||
resp = _upload(client, auth_headers, entryid, toobig)
|
||||
assert resp.status_code == 400
|
||||
assert ManifestPayload.query.filter_by(entryid=entryid).count() == 0
|
||||
|
||||
|
||||
def test_empty_and_missing_file_rejected(client, db, auth_headers):
|
||||
scopeid = _create_scope(client, auth_headers)
|
||||
entryid = _add_entry(client, auth_headers, scopeid)
|
||||
empty = _upload(client, auth_headers, entryid, b'')
|
||||
assert empty.status_code == 400
|
||||
nofile = client.post(f'/api/geenforce/entries/{entryid}/payload',
|
||||
data={}, content_type='multipart/form-data',
|
||||
headers=auth_headers)
|
||||
assert nofile.status_code == 400
|
||||
|
||||
|
||||
def test_download_returns_exact_bytes(client, db, auth_headers):
|
||||
scopeid = _create_scope(client, auth_headers)
|
||||
entryid = _add_entry(client, auth_headers, scopeid)
|
||||
content = b'\x00\x01binary payload\xff'
|
||||
_upload(client, auth_headers, entryid, content, filename='blob.bin')
|
||||
|
||||
resp = client.get(f'/api/geenforce/entries/{entryid}/payload',
|
||||
headers=auth_headers)
|
||||
assert resp.status_code == 200
|
||||
assert resp.data == content
|
||||
assert 'blob.bin' in resp.headers['Content-Disposition']
|
||||
|
||||
|
||||
def test_download_404_when_no_payload(client, db, auth_headers):
|
||||
scopeid = _create_scope(client, auth_headers)
|
||||
entryid = _add_entry(client, auth_headers, scopeid)
|
||||
resp = client.get(f'/api/geenforce/entries/{entryid}/payload',
|
||||
headers=auth_headers)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_upload_404_unknown_entry(client, db, auth_headers):
|
||||
resp = _upload(client, auth_headers, 999999, b'data')
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_upload_requires_publish_permission(client, db, auth_headers,
|
||||
member_headers):
|
||||
scopeid = _create_scope(client, auth_headers)
|
||||
entryid = _add_entry(client, auth_headers, scopeid)
|
||||
# member (no permissions) cannot upload.
|
||||
resp = _upload(client, member_headers, entryid, b'data')
|
||||
assert resp.status_code == 403
|
||||
183
tests/test_plugins/test_geenforce_reporting.py
Normal file
183
tests/test_plugins/test_geenforce_reporting.py
Normal file
@@ -0,0 +1,183 @@
|
||||
"""GE-Enforce observed-state reporting: PCs POST enforcement results.
|
||||
|
||||
Covers the report ingest (applied version + per-entry self-heal outcomes), the
|
||||
latest-per-host upsert, received-latest derivation (did the PC pick up the newest
|
||||
published manifest?), status derivation (ok / selfhealed / failed), and the admin
|
||||
fleet-compliance views.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
from plugins.geenforce import service
|
||||
|
||||
|
||||
SCOPE = {
|
||||
'Version': '2.6',
|
||||
'Applications': [
|
||||
{'Name': 'Alpha', 'Type': 'MSI', 'Installer': 'apps/alpha.msi'},
|
||||
{'Name': 'Beta', 'Type': 'PS1', 'Script': 'scripts/beta.ps1'},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _seed_and_publish(app, scopename='gea-shopfloor-cmm'):
|
||||
with app.app_context():
|
||||
service.replace_scope_draft(scopename, 'runtime', SCOPE)
|
||||
version = service.publish_scope(scopename, 'runtime', notes='v')
|
||||
service.db.session.commit()
|
||||
return version
|
||||
|
||||
|
||||
def _token(client, auth_headers, scopes):
|
||||
resp = client.post('/api/apitokens',
|
||||
json={'name': 'svc', 'scopes': scopes},
|
||||
headers=auth_headers)
|
||||
assert resp.status_code == 201, resp.get_json()
|
||||
return resp.get_json()['data']['secret']
|
||||
|
||||
|
||||
def test_report_malformed_counts_is_400(client, db, app, auth_headers):
|
||||
"""A wrong JSON shape (counts as a string, results as a string) must be a
|
||||
clean 400, not a 500 from a later .get()/iteration."""
|
||||
_seed_and_publish(app)
|
||||
secret = _token(client, auth_headers, ['geenforce.report'])
|
||||
for bad in ({'counts': 'nope'}, {'results': 'nope'}):
|
||||
body = {'hostname': 'WJCMM01', 'scopename': 'gea-shopfloor-cmm'}
|
||||
body.update(bad)
|
||||
resp = client.post('/api/geenforce/report', json=body,
|
||||
headers={'X-API-Key': secret})
|
||||
assert resp.status_code == 400, resp.get_json()
|
||||
|
||||
|
||||
def test_report_recorded_and_listed(client, db, app, auth_headers):
|
||||
_seed_and_publish(app)
|
||||
secret = _token(client, auth_headers, ['geenforce.report'])
|
||||
resp = client.post('/api/geenforce/report', json={
|
||||
'hostname': 'WJCMM01', 'scopename': 'gea-shopfloor-cmm',
|
||||
'appliedversion': 1, 'enforcerversion': '2.6',
|
||||
'counts': {'installed': 1, 'skipped': 1, 'failed': 0, 'filtered': 0},
|
||||
'results': [
|
||||
{'name': 'Alpha', 'action': 'installed', 'selfhealed': True},
|
||||
{'name': 'Beta', 'action': 'skipped'},
|
||||
],
|
||||
}, headers={'X-API-Key': secret})
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
assert resp.get_json()['data']['status'] == 'selfhealed'
|
||||
|
||||
listing = client.get('/api/geenforce/reports', headers=auth_headers)
|
||||
rows = listing.get_json()['data']
|
||||
assert len(rows) == 1
|
||||
row = rows[0]
|
||||
assert row['hostname'] == 'WJCMM01'
|
||||
assert row['appliedversion'] == 1
|
||||
assert row['latestversion'] == 1
|
||||
assert row['receivedlatest'] is True
|
||||
assert row['installed'] == 1 and row['skipped'] == 1
|
||||
|
||||
|
||||
def test_received_latest_flips_when_new_version_published(client, db, app,
|
||||
auth_headers):
|
||||
_seed_and_publish(app)
|
||||
secret = _token(client, auth_headers, ['geenforce.report'])
|
||||
client.post('/api/geenforce/report',
|
||||
json={'hostname': 'WJCMM02', 'scopename': 'gea-shopfloor-cmm',
|
||||
'appliedversion': 1, 'counts': {}},
|
||||
headers={'X-API-Key': secret})
|
||||
# Publish a newer version; the PC is now behind.
|
||||
with app.app_context():
|
||||
service.publish_scope('gea-shopfloor-cmm', 'runtime', notes='v2')
|
||||
service.db.session.commit()
|
||||
|
||||
row = client.get('/api/geenforce/reports?hostname=WJCMM02',
|
||||
headers=auth_headers).get_json()['data'][0]
|
||||
assert row['appliedversion'] == 1
|
||||
assert row['latestversion'] == 2
|
||||
assert row['receivedlatest'] is False
|
||||
|
||||
|
||||
def test_latest_report_upserts_per_host(client, db, app, auth_headers):
|
||||
_seed_and_publish(app)
|
||||
secret = _token(client, auth_headers, ['geenforce.report'])
|
||||
for installed in (0, 1):
|
||||
client.post('/api/geenforce/report',
|
||||
json={'hostname': 'WJCMM03', 'scopename': 'gea-shopfloor-cmm',
|
||||
'appliedversion': 1,
|
||||
'counts': {'installed': installed}},
|
||||
headers={'X-API-Key': secret})
|
||||
rows = client.get('/api/geenforce/reports?hostname=WJCMM03',
|
||||
headers=auth_headers).get_json()['data']
|
||||
assert len(rows) == 1 # only the latest is current
|
||||
assert rows[0]['installed'] == 1
|
||||
|
||||
|
||||
def test_hostname_case_variation_keeps_single_current(client, db, app, auth_headers):
|
||||
"""A PC reporting its hostname in different casing must not leave two
|
||||
iscurrent rows (the demote is case-insensitive, matching the ilike read)."""
|
||||
_seed_and_publish(app)
|
||||
secret = _token(client, auth_headers, ['geenforce.report'])
|
||||
for host in ('WJCMM09', 'wjcmm09'):
|
||||
client.post('/api/geenforce/report',
|
||||
json={'hostname': host, 'scopename': 'gea-shopfloor-cmm',
|
||||
'appliedversion': 1, 'counts': {'installed': 1}},
|
||||
headers={'X-API-Key': secret})
|
||||
from plugins.geenforce.models import ManifestEnforcementReport
|
||||
with app.app_context():
|
||||
current = ManifestEnforcementReport.query.filter(
|
||||
ManifestEnforcementReport.hostname.ilike('WJCMM09'),
|
||||
ManifestEnforcementReport.iscurrent == True).all()
|
||||
assert len(current) == 1
|
||||
|
||||
|
||||
def test_status_failed_when_failures(client, db, app, auth_headers):
|
||||
_seed_and_publish(app)
|
||||
secret = _token(client, auth_headers, ['geenforce.report'])
|
||||
resp = client.post('/api/geenforce/report',
|
||||
json={'hostname': 'WJCMM04', 'scopename': 'gea-shopfloor-cmm',
|
||||
'counts': {'failed': 1},
|
||||
'results': [{'name': 'Alpha', 'action': 'failed',
|
||||
'exitcode': 1603,
|
||||
'message': 'MSI 1603'}]},
|
||||
headers={'X-API-Key': secret})
|
||||
assert resp.get_json()['data']['status'] == 'failed'
|
||||
|
||||
|
||||
def test_report_detail_shows_per_entry_results(client, db, app, auth_headers):
|
||||
_seed_and_publish(app)
|
||||
secret = _token(client, auth_headers, ['geenforce.report'])
|
||||
post = client.post('/api/geenforce/report',
|
||||
json={'hostname': 'WJCMM05', 'scopename': 'gea-shopfloor-cmm',
|
||||
'results': [
|
||||
{'name': 'Alpha', 'action': 'installed',
|
||||
'selfhealed': True},
|
||||
{'name': 'Beta', 'action': 'failed',
|
||||
'exitcode': 1, 'message': 'boom'}]},
|
||||
headers={'X-API-Key': secret})
|
||||
reportid = post.get_json()['data']['reportid']
|
||||
detail = client.get(f'/api/geenforce/reports/{reportid}',
|
||||
headers=auth_headers).get_json()['data']
|
||||
results = {r['entryname']: r for r in detail['results']}
|
||||
assert results['Alpha']['selfhealed'] is True
|
||||
assert results['Beta']['action'] == 'failed'
|
||||
assert results['Beta']['message'] == 'boom'
|
||||
|
||||
|
||||
def test_report_requires_report_scope(client, db, app, auth_headers):
|
||||
_seed_and_publish(app)
|
||||
# A fetch-only token cannot report.
|
||||
secret = _token(client, auth_headers, ['geenforce.fetch'])
|
||||
resp = client.post('/api/geenforce/report',
|
||||
json={'hostname': 'WJCMM06'},
|
||||
headers={'X-API-Key': secret})
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
def test_report_unauthenticated_rejected(client, db, app):
|
||||
resp = client.post('/api/geenforce/report', json={'hostname': 'WJCMM07'})
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
def test_report_missing_hostname_rejected(client, db, app, auth_headers):
|
||||
secret = _token(client, auth_headers, ['geenforce.report'])
|
||||
resp = client.post('/api/geenforce/report', json={'scopename': 'x'},
|
||||
headers={'X-API-Key': secret})
|
||||
assert resp.status_code == 400
|
||||
57
tests/test_plugins/test_installed_apps.py
Normal file
57
tests/test_plugins/test_installed_apps.py
Normal file
@@ -0,0 +1,57 @@
|
||||
"""Installed-applications detail endpoint serializes correctly.
|
||||
|
||||
Regression guard: GET /api/applications/machines/<computerid> serializes each
|
||||
ComputerInstalledApp via its to_dict(). A missing to_dict raised a 500 that the
|
||||
PCDetail page swallowed (v-if on a non-empty list), so real PCs silently showed
|
||||
no installed software. This exercises the with-data path that the empty-list
|
||||
case short-circuits past.
|
||||
"""
|
||||
|
||||
from shopdb.extensions import db as _db
|
||||
from shopdb.core.models import Asset, AssetType, Application
|
||||
|
||||
from plugins.computers.models import Computer, ComputerInstalledApp
|
||||
|
||||
|
||||
def _seed_pc_with_app(client_db):
|
||||
atype = AssetType.query.filter_by(assettype='computer').first()
|
||||
if not atype:
|
||||
atype = AssetType(assettype='computer')
|
||||
_db.session.add(atype)
|
||||
_db.session.flush()
|
||||
asset = Asset(assetnumber='PC-APPTEST', assettypeid=atype.assettypeid)
|
||||
_db.session.add(asset)
|
||||
_db.session.flush()
|
||||
comp = Computer(assetid=asset.assetid, hostname='PC-APPTEST')
|
||||
_db.session.add(comp)
|
||||
_db.session.flush()
|
||||
app = Application(appname='Test App')
|
||||
_db.session.add(app)
|
||||
_db.session.flush()
|
||||
link = ComputerInstalledApp(computerid=comp.computerid, appid=app.appid,
|
||||
installedversion='1.2.3')
|
||||
_db.session.add(link)
|
||||
_db.session.commit()
|
||||
return comp
|
||||
|
||||
|
||||
def test_installed_apps_endpoint_returns_data(client, db, auth_headers):
|
||||
comp = _seed_pc_with_app(db)
|
||||
resp = client.get(f'/api/applications/machines/{comp.computerid}',
|
||||
headers=auth_headers)
|
||||
assert resp.status_code == 200
|
||||
rows = resp.get_json()['data']
|
||||
assert len(rows) == 1
|
||||
row = rows[0]
|
||||
assert row['appname'] == 'Test App'
|
||||
assert row['installedversion'] == '1.2.3'
|
||||
assert row['computerid'] == comp.computerid
|
||||
|
||||
|
||||
def test_installed_app_to_dict_prefers_curated_version(db):
|
||||
comp = _seed_pc_with_app(db)
|
||||
link = ComputerInstalledApp.query.filter_by(computerid=comp.computerid).first()
|
||||
data = link.to_dict()
|
||||
assert data['appname'] == 'Test App'
|
||||
assert data['installedversion'] == '1.2.3'
|
||||
assert data['isactive'] is True
|
||||
475
tests/test_plugins/test_measuringtools.py
Normal file
475
tests/test_plugins/test_measuringtools.py
Normal file
@@ -0,0 +1,475 @@
|
||||
"""Tests for the measuringtools plugin.
|
||||
|
||||
The plugin ships default-disabled, so the shared session app does not register
|
||||
its blueprint. This module builds its own app, registers the blueprint, runs the
|
||||
plugin's on_install seeding, and restores the process-wide plugin_manager
|
||||
singleton afterwards (create_app repoints it) so other test modules are
|
||||
unaffected - the same snapshot/restore dance used by test_plugin_migrations.
|
||||
|
||||
Coverage: derived calibration status (all four buckets + the 30-day boundary),
|
||||
type CRUD with the in-use delete guard, tool create/update in one merged
|
||||
payload, and the calibration report shape.
|
||||
"""
|
||||
|
||||
from datetime import date, timedelta
|
||||
|
||||
import pytest
|
||||
from werkzeug.security import generate_password_hash
|
||||
|
||||
from shopdb import create_app
|
||||
from shopdb.extensions import db as _db
|
||||
from shopdb.plugins import plugin_manager
|
||||
from plugins.measuringtools.models import (
|
||||
derive_status, DUESOON_WINDOW_DAYS, STATUS_COLORS)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Pure unit tests: derived status (no app needed)
|
||||
# =============================================================================
|
||||
|
||||
def test_derive_status_unknown_when_no_date():
|
||||
assert derive_status(None) == 'unknown'
|
||||
|
||||
|
||||
def test_derive_status_overdue_in_past():
|
||||
assert derive_status(date.today() - timedelta(days=1)) == 'overdue'
|
||||
|
||||
|
||||
def test_derive_status_current_far_future():
|
||||
assert derive_status(date.today() + timedelta(days=DUESOON_WINDOW_DAYS + 1)) == 'current'
|
||||
|
||||
|
||||
def test_derive_status_duesoon_within_window():
|
||||
assert derive_status(date.today() + timedelta(days=DUESOON_WINDOW_DAYS - 1)) == 'duesoon'
|
||||
|
||||
|
||||
def test_derive_status_duesoon_on_boundary():
|
||||
"""Exactly DUESOON_WINDOW_DAYS out is still 'due soon' (inclusive)."""
|
||||
assert derive_status(date.today() + timedelta(days=DUESOON_WINDOW_DAYS)) == 'duesoon'
|
||||
|
||||
|
||||
def test_derive_status_duesoon_today():
|
||||
"""Due today is not yet overdue - it falls in the due-soon window."""
|
||||
assert derive_status(date.today()) == 'duesoon'
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# API tests (self-contained app with the blueprint registered)
|
||||
# =============================================================================
|
||||
|
||||
@pytest.fixture(scope='module')
|
||||
def mt_app():
|
||||
"""A testing app with the measuringtools blueprint registered + seeded."""
|
||||
saved = (plugin_manager._app, plugin_manager._db, plugin_manager.registry,
|
||||
plugin_manager.loader, plugin_manager.migration_manager,
|
||||
plugin_manager._registered_prefixes)
|
||||
application = create_app('testing')
|
||||
|
||||
from plugins.measuringtools.plugin import MeasuringToolsPlugin
|
||||
plugin = MeasuringToolsPlugin()
|
||||
pm = application.extensions['plugin_manager']
|
||||
if plugin.meta.api_prefix not in pm._registered_prefixes:
|
||||
pm._register_plugin_components(plugin)
|
||||
|
||||
with application.app_context():
|
||||
_db.create_all()
|
||||
_seed(application, plugin)
|
||||
yield application
|
||||
_db.session.remove()
|
||||
_db.drop_all()
|
||||
|
||||
(plugin_manager._app, plugin_manager._db, plugin_manager.registry,
|
||||
plugin_manager.loader, plugin_manager.migration_manager,
|
||||
plugin_manager._registered_prefixes) = saved
|
||||
|
||||
|
||||
def _seed(application, plugin):
|
||||
"""Seed permissions, an admin, a status, and the plugin's own reference data."""
|
||||
from shopdb.core.models import Permission, User, Role, AssetStatus
|
||||
Permission.seed()
|
||||
role = Role(rolename='admin', description='Administrator')
|
||||
_db.session.add(role)
|
||||
_db.session.add(AssetStatus(status='In Use', description='In use'))
|
||||
_db.session.flush()
|
||||
user = User(username='testadmin', email='admin@test.local',
|
||||
passwordhash=generate_password_hash('testpass'))
|
||||
user.roles.append(role)
|
||||
_db.session.add(user)
|
||||
_db.session.commit()
|
||||
# Seeds AssetType 'measuring_tool' + the starter tool types.
|
||||
plugin.on_install(application)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(mt_app):
|
||||
return mt_app.test_client()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def auth_headers(client):
|
||||
response = client.post('/api/auth/login',
|
||||
json={'username': 'testadmin', 'password': 'testpass'})
|
||||
assert response.status_code == 200, response.get_json()
|
||||
token = response.get_json()['data']['access_token']
|
||||
return {'Authorization': f'Bearer {token}'}
|
||||
|
||||
|
||||
def _status_id(client):
|
||||
return 1 # only status seeded
|
||||
|
||||
|
||||
# -- Type CRUD + in-use guard -------------------------------------------------
|
||||
|
||||
def test_starter_types_seeded(client):
|
||||
response = client.get('/api/measuringtools/types')
|
||||
assert response.status_code == 200
|
||||
names = {t['name'] for t in response.get_json()['data']}
|
||||
assert {'Caliper', 'Micrometer', 'Thread Gage', 'Other'} <= names
|
||||
|
||||
|
||||
def test_type_create_requires_auth(client):
|
||||
response = client.post('/api/measuringtools/types', json={'name': 'Nope'})
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_type_crud_lifecycle(client, auth_headers):
|
||||
created = client.post('/api/measuringtools/types', headers=auth_headers,
|
||||
json={'name': 'Pin Gage', 'description': 'Pin gage set',
|
||||
'color': '#123456'})
|
||||
assert created.status_code == 201, created.get_json()
|
||||
type_id = created.get_json()['data']['measuringtooltypeid']
|
||||
|
||||
updated = client.put(f'/api/measuringtools/types/{type_id}', headers=auth_headers,
|
||||
json={'description': 'Updated'})
|
||||
assert updated.status_code == 200
|
||||
assert updated.get_json()['data']['description'] == 'Updated'
|
||||
|
||||
dup = client.post('/api/measuringtools/types', headers=auth_headers,
|
||||
json={'name': 'Pin Gage'})
|
||||
assert dup.status_code == 409
|
||||
|
||||
deleted = client.delete(f'/api/measuringtools/types/{type_id}', headers=auth_headers)
|
||||
assert deleted.status_code == 200
|
||||
|
||||
|
||||
def test_type_delete_blocked_when_in_use(client, auth_headers):
|
||||
type_created = client.post('/api/measuringtools/types', headers=auth_headers,
|
||||
json={'name': 'Depth Gage'})
|
||||
type_id = type_created.get_json()['data']['measuringtooltypeid']
|
||||
tool_created = client.post('/api/measuringtools', headers=auth_headers,
|
||||
json={'assetnumber': 'MT-INUSE-1',
|
||||
'measuringtooltypeid': type_id,
|
||||
'statusid': _status_id(client)})
|
||||
assert tool_created.status_code == 201, tool_created.get_json()
|
||||
|
||||
blocked = client.delete(f'/api/measuringtools/types/{type_id}', headers=auth_headers)
|
||||
assert blocked.status_code == 409
|
||||
assert 'still use this type' in blocked.get_json()['data']['error']['message']
|
||||
|
||||
|
||||
# -- Tool create/update merged payload ----------------------------------------
|
||||
|
||||
def test_tool_create_and_get_merged(client, auth_headers):
|
||||
caliper = client.get('/api/measuringtools/types').get_json()['data']
|
||||
caliper_id = next(t['measuringtooltypeid'] for t in caliper if t['name'] == 'Caliper')
|
||||
|
||||
created = client.post('/api/measuringtools', headers=auth_headers, json={
|
||||
'assetnumber': 'MT-001',
|
||||
'name': 'Bench caliper',
|
||||
'serialnumber': 'SN-CAL-9',
|
||||
'gaugelabreference': 'GL-42',
|
||||
'measuringtooltypeid': caliper_id,
|
||||
'statusid': _status_id(client),
|
||||
'calibrationintervaldays': 365,
|
||||
'lastcalibrationdate': '2026-01-01',
|
||||
'nextcalibrationdate': '2027-01-01',
|
||||
'calibrationprovider': 'Metro Cal Lab',
|
||||
})
|
||||
assert created.status_code == 201, created.get_json()
|
||||
payload = created.get_json()['data']
|
||||
# Asset core fields at the top level, extension nested.
|
||||
assert payload['assetnumber'] == 'MT-001'
|
||||
assert payload['serialnumber'] == 'SN-CAL-9'
|
||||
assert payload['gaugelabreference'] == 'GL-42'
|
||||
ext = payload['measuringtool']
|
||||
assert ext['measuringtooltypename'] == 'Caliper'
|
||||
assert ext['calibrationprovider'] == 'Metro Cal Lab'
|
||||
assert ext['calibrationstatus'] == 'current'
|
||||
tool_id = ext['measuringtoolid']
|
||||
|
||||
fetched = client.get(f'/api/measuringtools/{tool_id}')
|
||||
assert fetched.status_code == 200
|
||||
assert fetched.get_json()['data']['measuringtool']['measuringtoolid'] == tool_id
|
||||
|
||||
|
||||
def test_tool_payload_carries_location_code_and_name(mt_app, client, auth_headers):
|
||||
"""A tool at an operation location exposes locationcode (leading token)
|
||||
and locationname so a printed label can encode the inspection operation."""
|
||||
with mt_app.app_context():
|
||||
from shopdb.core.models import Location
|
||||
location = Location(locationname='0615 Blisk Inspection')
|
||||
_db.session.add(location)
|
||||
_db.session.commit()
|
||||
location_id = location.locationid
|
||||
|
||||
created = client.post('/api/measuringtools', headers=auth_headers, json={
|
||||
'assetnumber': 'MT-LOC-1', 'statusid': _status_id(client),
|
||||
'locationid': location_id,
|
||||
})
|
||||
assert created.status_code == 201, created.get_json()
|
||||
payload = created.get_json()['data']
|
||||
assert payload['locationname'] == '0615 Blisk Inspection'
|
||||
assert payload['locationcode'] == '0615'
|
||||
|
||||
|
||||
def test_tool_payload_location_code_none_when_unplaced(client, auth_headers):
|
||||
"""A tool with no location degrades gracefully: locationcode is None."""
|
||||
created = client.post('/api/measuringtools', headers=auth_headers, json={
|
||||
'assetnumber': 'MT-NOLOC-1', 'statusid': _status_id(client),
|
||||
})
|
||||
assert created.status_code == 201, created.get_json()
|
||||
assert created.get_json()['data']['locationcode'] is None
|
||||
|
||||
|
||||
def test_tool_update_merged_payload(client, auth_headers):
|
||||
created = client.post('/api/measuringtools', headers=auth_headers, json={
|
||||
'assetnumber': 'MT-UPD-1', 'statusid': _status_id(client),
|
||||
'nextcalibrationdate': '2030-01-01',
|
||||
})
|
||||
tool_id = created.get_json()['data']['measuringtool']['measuringtoolid']
|
||||
|
||||
updated = client.put(f'/api/measuringtools/{tool_id}', headers=auth_headers, json={
|
||||
'name': 'Renamed tool', # asset core field
|
||||
'calibrationprovider': 'In-house', # extension field
|
||||
'nextcalibrationdate': str(date.today() - timedelta(days=5)), # -> overdue
|
||||
})
|
||||
assert updated.status_code == 200, updated.get_json()
|
||||
data = updated.get_json()['data']
|
||||
assert data['name'] == 'Renamed tool'
|
||||
assert data['measuringtool']['calibrationprovider'] == 'In-house'
|
||||
assert data['measuringtool']['calibrationstatus'] == 'overdue'
|
||||
|
||||
|
||||
def test_tool_create_duplicate_assetnumber_conflicts(client, auth_headers):
|
||||
body = {'assetnumber': 'MT-DUP', 'statusid': _status_id(client)}
|
||||
first = client.post('/api/measuringtools', headers=auth_headers, json=body)
|
||||
assert first.status_code == 201
|
||||
second = client.post('/api/measuringtools', headers=auth_headers, json=body)
|
||||
assert second.status_code == 409
|
||||
|
||||
|
||||
def test_list_filter_by_calibrationstatus(client, auth_headers):
|
||||
client.post('/api/measuringtools', headers=auth_headers, json={
|
||||
'assetnumber': 'MT-OVERDUE-1', 'statusid': _status_id(client),
|
||||
'nextcalibrationdate': str(date.today() - timedelta(days=10)),
|
||||
})
|
||||
response = client.get('/api/measuringtools?calibrationstatus=overdue')
|
||||
assert response.status_code == 200
|
||||
rows = response.get_json()['data']
|
||||
assert rows, 'expected at least one overdue tool'
|
||||
assert all(r['measuringtool']['calibrationstatus'] == 'overdue' for r in rows)
|
||||
|
||||
|
||||
# -- Maintenance reference identifier -----------------------------------------
|
||||
|
||||
def test_maintenancereference_roundtrip(client, auth_headers):
|
||||
"""Maintenance reference persists on create and update."""
|
||||
created = client.post('/api/measuringtools', headers=auth_headers, json={
|
||||
'assetnumber': 'MT-MNT-1', 'statusid': _status_id(client),
|
||||
'maintenancereference': 'MNT-9',
|
||||
})
|
||||
assert created.status_code == 201, created.get_json()
|
||||
assert created.get_json()['data']['maintenancereference'] == 'MNT-9'
|
||||
tool_id = created.get_json()['data']['measuringtool']['measuringtoolid']
|
||||
|
||||
updated = client.put(f'/api/measuringtools/{tool_id}', headers=auth_headers,
|
||||
json={'maintenancereference': 'MNT-10'})
|
||||
assert updated.status_code == 200
|
||||
assert updated.get_json()['data']['maintenancereference'] == 'MNT-10'
|
||||
|
||||
|
||||
# -- Global search ------------------------------------------------------------
|
||||
|
||||
def _mt_search_hits(client, auth_headers, term):
|
||||
resp = client.get(f'/api/search?q={term}', headers=auth_headers)
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
return [r for r in resp.get_json()['data']['results']
|
||||
if r.get('type') == 'measuring_tool']
|
||||
|
||||
|
||||
def test_search_finds_tool_by_assetnumber(mt_app, client, auth_headers, monkeypatch):
|
||||
"""A measuring tool appears in global search by asset number, routed right."""
|
||||
client.post('/api/measuringtools', headers=auth_headers, json={
|
||||
'assetnumber': 'MT-SEARCH-AN', 'statusid': _status_id(client)})
|
||||
pm = mt_app.extensions['plugin_manager']
|
||||
monkeypatch.setattr(pm.registry, 'is_enabled', lambda name: True)
|
||||
hits = _mt_search_hits(client, auth_headers, 'MT-SEARCH-AN')
|
||||
assert hits
|
||||
assert hits[0]['url'].startswith('/measuringtools/')
|
||||
|
||||
|
||||
def test_search_finds_tool_by_gaugelabreference(mt_app, client, auth_headers, monkeypatch):
|
||||
"""A gage-tag lookup by gaugelabreference finds the tool."""
|
||||
client.post('/api/measuringtools', headers=auth_headers, json={
|
||||
'assetnumber': 'MT-SEARCH-GL', 'statusid': _status_id(client),
|
||||
'gaugelabreference': 'GLREF-778'})
|
||||
pm = mt_app.extensions['plugin_manager']
|
||||
monkeypatch.setattr(pm.registry, 'is_enabled', lambda name: True)
|
||||
hits = _mt_search_hits(client, auth_headers, 'GLREF-778')
|
||||
assert hits
|
||||
assert hits[0]['url'].startswith('/measuringtools/')
|
||||
|
||||
|
||||
def test_search_excludes_tools_when_domain_disabled(mt_app, client, auth_headers, monkeypatch):
|
||||
"""search_measuring_tool_enabled=false hides measuring tools from search."""
|
||||
from shopdb.core.models import Setting
|
||||
client.post('/api/measuringtools', headers=auth_headers, json={
|
||||
'assetnumber': 'MT-SEARCH-OFF', 'statusid': _status_id(client),
|
||||
'gaugelabreference': 'GLREF-OFF-1'})
|
||||
pm = mt_app.extensions['plugin_manager']
|
||||
monkeypatch.setattr(pm.registry, 'is_enabled', lambda name: True)
|
||||
Setting.set('search_measuring_tool_enabled', False, valuetype='boolean',
|
||||
category='search')
|
||||
try:
|
||||
assert _mt_search_hits(client, auth_headers, 'MT-SEARCH-OFF') == []
|
||||
assert _mt_search_hits(client, auth_headers, 'GLREF-OFF-1') == []
|
||||
finally:
|
||||
Setting.set('search_measuring_tool_enabled', True, valuetype='boolean',
|
||||
category='search')
|
||||
|
||||
|
||||
# -- Report shape -------------------------------------------------------------
|
||||
|
||||
def test_calibration_report_shape(client, auth_headers):
|
||||
response = client.get('/api/measuringtools/report/calibration')
|
||||
assert response.status_code == 200
|
||||
data = response.get_json()['data']
|
||||
assert set(data['counts']) == {'overdue', 'duesoon', 'current', 'unknown'}
|
||||
assert set(data['buckets']) == {'overdue', 'duesoon', 'current', 'unknown'}
|
||||
# Buckets and counts agree.
|
||||
for key, rows in data['buckets'].items():
|
||||
assert data['counts'][key] == len(rows)
|
||||
assert 'statuscolors' in data
|
||||
|
||||
|
||||
# -- Core integration: asset serialization ------------------------------------
|
||||
|
||||
def _caliper_id(client):
|
||||
types = client.get('/api/measuringtools/types').get_json()['data']
|
||||
return next(t['measuringtooltypeid'] for t in types if t['name'] == 'Caliper')
|
||||
|
||||
|
||||
def test_asset_todict_carries_measuringtool_typedata_and_pluginid(mt_app, client, auth_headers):
|
||||
"""Asset.to_dict resolves the measuringtool extension (typedata + pluginid)."""
|
||||
from shopdb.core.models import Asset
|
||||
created = client.post('/api/measuringtools', headers=auth_headers, json={
|
||||
'assetnumber': 'MT-TD-1', 'statusid': _status_id(client),
|
||||
'measuringtooltypeid': _caliper_id(client),
|
||||
})
|
||||
assert created.status_code == 201, created.get_json()
|
||||
tool_id = created.get_json()['data']['measuringtool']['measuringtoolid']
|
||||
assetid = created.get_json()['data']['assetid']
|
||||
|
||||
with mt_app.app_context():
|
||||
asset = _db.session.get(Asset, assetid)
|
||||
result = asset.to_dict(include_type_data=True)
|
||||
assert result['pluginid'] == tool_id
|
||||
assert result['typedata']['measuringtooltypename'] == 'Caliper'
|
||||
assert result['typedata']['measuringtoolid'] == tool_id
|
||||
|
||||
|
||||
# -- Core integration: shop-floor map -----------------------------------------
|
||||
|
||||
def test_map_lists_measuringtool_subtypes(client, auth_headers):
|
||||
"""The map filter dropdown carries a Measuring Tool subtype list with color."""
|
||||
response = client.get('/api/assets/map')
|
||||
assert response.status_code == 200, response.get_json()
|
||||
subtypes = response.get_json()['data']['filters']['subtypes']
|
||||
assert 'Measuring Tool' in subtypes
|
||||
names = {s['name'] for s in subtypes['Measuring Tool']}
|
||||
assert 'Caliper' in names
|
||||
assert all('color' in s for s in subtypes['Measuring Tool'])
|
||||
|
||||
|
||||
def test_map_honors_measuringtool_subtype_filter(client, auth_headers):
|
||||
"""?assettype=measuring_tool&subtype=<id> returns only that subtype's tools."""
|
||||
types = client.get('/api/measuringtools/types').get_json()['data']
|
||||
caliper_id = next(t['measuringtooltypeid'] for t in types if t['name'] == 'Caliper')
|
||||
micrometer_id = next(t['measuringtooltypeid'] for t in types if t['name'] == 'Micrometer')
|
||||
client.post('/api/measuringtools', headers=auth_headers, json={
|
||||
'assetnumber': 'MT-MAP-CAL', 'statusid': _status_id(client),
|
||||
'measuringtooltypeid': caliper_id, 'mapx': 10, 'mapy': 20})
|
||||
client.post('/api/measuringtools', headers=auth_headers, json={
|
||||
'assetnumber': 'MT-MAP-MIC', 'statusid': _status_id(client),
|
||||
'measuringtooltypeid': micrometer_id, 'mapx': 30, 'mapy': 40})
|
||||
|
||||
response = client.get(
|
||||
f'/api/assets/map?assettype=measuring_tool&subtype={caliper_id}')
|
||||
assert response.status_code == 200, response.get_json()
|
||||
numbers = {a['assetnumber'] for a in response.get_json()['data']['assets']}
|
||||
assert 'MT-MAP-CAL' in numbers
|
||||
assert 'MT-MAP-MIC' not in numbers
|
||||
|
||||
|
||||
def test_map_item_carries_measuringtool_typedata(client, auth_headers):
|
||||
"""A mapped tool's item carries the extension typedata for marker coloring."""
|
||||
client.post('/api/measuringtools', headers=auth_headers, json={
|
||||
'assetnumber': 'MT-MAP-TD', 'statusid': _status_id(client),
|
||||
'measuringtooltypeid': _caliper_id(client), 'mapx': 55, 'mapy': 66})
|
||||
response = client.get('/api/assets/map?assettype=measuring_tool')
|
||||
item = next(a for a in response.get_json()['data']['assets']
|
||||
if a['assetnumber'] == 'MT-MAP-TD')
|
||||
assert item['typedata']['measuringtooltypename'] == 'Caliper'
|
||||
|
||||
|
||||
# -- Core integration: dashboard ----------------------------------------------
|
||||
|
||||
def test_dashboard_counts_include_measuringtools(client, auth_headers):
|
||||
"""Dashboard total and counts include active measuring tools."""
|
||||
before = client.get('/api/dashboard').get_json()['data']
|
||||
client.post('/api/measuringtools', headers=auth_headers, json={
|
||||
'assetnumber': 'MT-DASH-1', 'statusid': _status_id(client)})
|
||||
after = client.get('/api/dashboard').get_json()['data']
|
||||
assert after['counts']['measuringtools'] == before['counts']['measuringtools'] + 1
|
||||
assert after['totalmeasuringtool'] == before['totalmeasuringtool'] + 1
|
||||
assert after['counts']['total'] == before['counts']['total'] + 1
|
||||
|
||||
|
||||
# -- Map overlay endpoint (ADR-010) -------------------------------------------
|
||||
|
||||
def test_map_overlay_shape_and_derivation(client, auth_headers):
|
||||
"""map-overlay returns per-asset derived calibration status + color."""
|
||||
created = client.post('/api/measuringtools', headers=auth_headers, json={
|
||||
'assetnumber': 'MT-OVL-1', 'statusid': _status_id(client),
|
||||
'nextcalibrationdate': str(date.today() - timedelta(days=3))}) # overdue
|
||||
assetid = created.get_json()['data']['assetid']
|
||||
|
||||
response = client.get('/api/measuringtools/map-overlay')
|
||||
assert response.status_code == 200, response.get_json()
|
||||
rows = response.get_json()['data']
|
||||
row = next(r for r in rows if r['assetid'] == assetid)
|
||||
assert set(row) == {'assetid', 'calibrationstatus', 'statuscolor'}
|
||||
assert row['calibrationstatus'] == 'overdue'
|
||||
assert row['statuscolor'] == STATUS_COLORS['overdue']
|
||||
|
||||
|
||||
def test_map_overlay_excludes_inactive(client, auth_headers):
|
||||
"""A soft-deleted tool drops out of the overlay."""
|
||||
created = client.post('/api/measuringtools', headers=auth_headers, json={
|
||||
'assetnumber': 'MT-OVL-DEL', 'statusid': _status_id(client)})
|
||||
tool_id = created.get_json()['data']['measuringtool']['measuringtoolid']
|
||||
assetid = created.get_json()['data']['assetid']
|
||||
client.delete(f'/api/measuringtools/{tool_id}', headers=auth_headers)
|
||||
|
||||
rows = client.get('/api/measuringtools/map-overlay').get_json()['data']
|
||||
assert all(r['assetid'] != assetid for r in rows)
|
||||
|
||||
|
||||
# -- Presentation route token (ADR-010) ---------------------------------------
|
||||
|
||||
def test_asset_presentation_route_token():
|
||||
"""Presentation route links through the by-asset resolver (only {assetid})."""
|
||||
from plugins.measuringtools.plugin import MeasuringToolsPlugin
|
||||
entries = MeasuringToolsPlugin().get_asset_presentation()
|
||||
entry = next(e for e in entries if e['assettype'] == 'measuring_tool')
|
||||
assert entry['route'] == '/measuringtools/by-asset/{assetid}'
|
||||
100
tests/test_plugins/test_model_image_in_detail.py
Normal file
100
tests/test_plugins/test_model_image_in_detail.py
Normal file
@@ -0,0 +1,100 @@
|
||||
"""Vendor-model photo surfaces in asset-extension detail payloads.
|
||||
|
||||
Machines, computers, and printers link a vendor Model via modelnumberid; when
|
||||
that Model carries an imageurl, the extension dict in the GET detail payload
|
||||
must expose it as 'imageurl' (the field name machines established, consumed by
|
||||
the detail-page heroes and the machine badge). When no model is linked, the
|
||||
field must be absent so the frontend v-if renders nothing.
|
||||
|
||||
Network devices and measuring tools have no model link, so they are out of
|
||||
scope here by design.
|
||||
"""
|
||||
|
||||
from shopdb.extensions import db as _db
|
||||
from shopdb.core.models import Asset, AssetType, Model
|
||||
|
||||
from plugins.machines.models import Machine
|
||||
from plugins.computers.models import Computer
|
||||
from plugins.printers.models import Printer
|
||||
|
||||
IMAGE_URL = '/api/models/images/model-1.png'
|
||||
|
||||
|
||||
def _seed_asset(assetnumber, assettype):
|
||||
atype = AssetType.query.filter_by(assettype=assettype).first()
|
||||
if not atype:
|
||||
atype = AssetType(assettype=assettype)
|
||||
_db.session.add(atype)
|
||||
_db.session.flush()
|
||||
asset = Asset(assetnumber=assetnumber, assettypeid=atype.assettypeid)
|
||||
_db.session.add(asset)
|
||||
_db.session.flush()
|
||||
return asset
|
||||
|
||||
|
||||
def _seed_model(imageurl=IMAGE_URL):
|
||||
model = Model(modelnumber='IMG-MODEL-1', imageurl=imageurl)
|
||||
_db.session.add(model)
|
||||
_db.session.flush()
|
||||
return model
|
||||
|
||||
|
||||
def test_machine_detail_carries_model_imageurl(client, db):
|
||||
asset = _seed_asset('2001', 'machine')
|
||||
model = _seed_model()
|
||||
machine = Machine(assetid=asset.assetid, modelnumberid=model.modelnumberid)
|
||||
_db.session.add(machine)
|
||||
_db.session.commit()
|
||||
|
||||
resp = client.get(f'/api/machines/{machine.machineid}')
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
assert resp.get_json()['data']['machine']['imageurl'] == IMAGE_URL
|
||||
|
||||
|
||||
def test_computer_detail_carries_model_imageurl(client, db):
|
||||
asset = _seed_asset('PC-2001', 'computer')
|
||||
model = _seed_model()
|
||||
computer = Computer(assetid=asset.assetid, modelnumberid=model.modelnumberid)
|
||||
_db.session.add(computer)
|
||||
_db.session.commit()
|
||||
|
||||
resp = client.get(f'/api/computers/{computer.computerid}')
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
assert resp.get_json()['data']['computer']['imageurl'] == IMAGE_URL
|
||||
|
||||
|
||||
def test_printer_detail_carries_model_imageurl(client, db):
|
||||
asset = _seed_asset('PR-2001', 'printer')
|
||||
model = _seed_model()
|
||||
printer = Printer(assetid=asset.assetid, modelnumberid=model.modelnumberid)
|
||||
_db.session.add(printer)
|
||||
_db.session.commit()
|
||||
|
||||
resp = client.get(f'/api/printers/{printer.printerid}')
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
assert resp.get_json()['data']['printer']['imageurl'] == IMAGE_URL
|
||||
|
||||
|
||||
def test_detail_omits_imageurl_without_model(client, db):
|
||||
"""No model linked -> no imageurl key (frontend v-if shows nothing)."""
|
||||
asset = _seed_asset('PR-2002', 'printer')
|
||||
printer = Printer(assetid=asset.assetid)
|
||||
_db.session.add(printer)
|
||||
_db.session.commit()
|
||||
|
||||
resp = client.get(f'/api/printers/{printer.printerid}')
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
assert 'imageurl' not in resp.get_json()['data']['printer']
|
||||
|
||||
|
||||
def test_detail_omits_imageurl_when_model_has_no_image(client, db):
|
||||
"""Model linked but no photo -> no imageurl key."""
|
||||
asset = _seed_asset('PC-2002', 'computer')
|
||||
model = _seed_model(imageurl=None)
|
||||
computer = Computer(assetid=asset.assetid, modelnumberid=model.modelnumberid)
|
||||
_db.session.add(computer)
|
||||
_db.session.commit()
|
||||
|
||||
resp = client.get(f'/api/computers/{computer.computerid}')
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
assert 'imageurl' not in resp.get_json()['data']['computer']
|
||||
121
tests/test_plugins/test_modelsupplies.py
Normal file
121
tests/test_plugins/test_modelsupplies.py
Normal file
@@ -0,0 +1,121 @@
|
||||
"""Tests for the model-supplies (toner part-number) management API."""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def model(db):
|
||||
"""A vendor + printer model to attach supplies to."""
|
||||
from shopdb.core.models import Vendor, Model
|
||||
|
||||
vendor = Vendor(vendor='TestVendor')
|
||||
db.session.add(vendor)
|
||||
db.session.flush()
|
||||
|
||||
model = Model(modelnumber='TestModel C999', vendorid=vendor.vendorid)
|
||||
db.session.add(model)
|
||||
db.session.commit()
|
||||
return model
|
||||
|
||||
|
||||
def test_supplies_meta_lists_allowed_values(client, db):
|
||||
response = client.get('/api/printers/supplies/meta')
|
||||
assert response.status_code == 200
|
||||
data = response.get_json()['data']
|
||||
assert 'toner' in data['supplytypes']
|
||||
assert 'black' in data['colors']
|
||||
assert 'metered' in data['capacitytiers']
|
||||
|
||||
|
||||
def test_create_and_list_model_supply(client, model, auth_headers):
|
||||
create = client.post(
|
||||
f'/api/printers/models/{model.modelnumberid}/supplies',
|
||||
json={
|
||||
'supplytype': 'toner', 'color': 'black', 'capacitytier': 'standard',
|
||||
'partnumber': 'W2020A', 'marketingname': '414A Black', 'pageyield': 2400,
|
||||
},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert create.status_code == 201
|
||||
|
||||
listing = client.get(f'/api/printers/models/{model.modelnumberid}/supplies')
|
||||
assert listing.status_code == 200
|
||||
supplies = listing.get_json()['data']['supplies']
|
||||
assert len(supplies) == 1
|
||||
assert supplies[0]['partnumber'] == 'W2020A'
|
||||
|
||||
|
||||
def test_duplicate_partnumber_rejected(client, model, auth_headers):
|
||||
payload = {'partnumber': 'W2020A', 'color': 'black'}
|
||||
client.post(f'/api/printers/models/{model.modelnumberid}/supplies',
|
||||
json=payload, headers=auth_headers)
|
||||
second = client.post(f'/api/printers/models/{model.modelnumberid}/supplies',
|
||||
json=payload, headers=auth_headers)
|
||||
assert second.status_code == 409
|
||||
|
||||
|
||||
def test_invalid_enum_rejected(client, model, auth_headers):
|
||||
response = client.post(
|
||||
f'/api/printers/models/{model.modelnumberid}/supplies',
|
||||
json={'partnumber': 'X1', 'color': 'purple'},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
def test_create_requires_auth(client, model):
|
||||
response = client.post(
|
||||
f'/api/printers/models/{model.modelnumberid}/supplies',
|
||||
json={'partnumber': 'X1'},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_update_and_delete_supply(client, model, auth_headers):
|
||||
created = client.post(
|
||||
f'/api/printers/models/{model.modelnumberid}/supplies',
|
||||
json={'partnumber': 'W2020A', 'color': 'black', 'capacitytier': 'standard'},
|
||||
headers=auth_headers,
|
||||
).get_json()['data']
|
||||
supplyid = created['modelsupplyid']
|
||||
|
||||
updated = client.put(
|
||||
f'/api/printers/supplies/{supplyid}',
|
||||
json={'capacitytier': 'high', 'marketingname': '414X Black'},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert updated.status_code == 200
|
||||
assert updated.get_json()['data']['capacitytier'] == 'high'
|
||||
|
||||
deleted = client.delete(f'/api/printers/supplies/{supplyid}', headers=auth_headers)
|
||||
assert deleted.status_code == 200
|
||||
|
||||
listing = client.get(f'/api/printers/models/{model.modelnumberid}/supplies')
|
||||
assert listing.get_json()['data']['supplies'] == []
|
||||
|
||||
|
||||
def test_listmodels_reports_supplycount(client, model, auth_headers):
|
||||
client.post(f'/api/printers/models/{model.modelnumberid}/supplies',
|
||||
json={'partnumber': 'W2020A', 'color': 'black'}, headers=auth_headers)
|
||||
|
||||
response = client.get('/api/printers/models', query_string={'search': 'C999'})
|
||||
assert response.status_code == 200
|
||||
rows = response.get_json()['data']
|
||||
match = next(r for r in rows if r['modelnumberid'] == model.modelnumberid)
|
||||
assert match['supplycount'] == 1
|
||||
|
||||
|
||||
def test_seed_supplies_corrected_data(app, db):
|
||||
"""The seed loads corrected part numbers (C405 colors, no B405 waste)."""
|
||||
with app.app_context():
|
||||
from plugins.printers.services import seedsupplies, lookupsupplies
|
||||
from shopdb.core.models import Model
|
||||
|
||||
seedsupplies()
|
||||
|
||||
c405 = Model.query.filter(Model.modelnumber.ilike('%C405%')).first()
|
||||
yellow = lookupsupplies(c405.modelnumberid, 'yellow', 'toner')
|
||||
assert any(s['partnumber'] == '106R03501' for s in yellow)
|
||||
|
||||
b405 = Model.query.filter(Model.modelnumber.ilike('%B405%')).first()
|
||||
assert lookupsupplies(b405.modelnumberid, 'none', 'waste') == []
|
||||
68
tests/test_plugins/test_network_ipaddress.py
Normal file
68
tests/test_plugins/test_network_ipaddress.py
Normal file
@@ -0,0 +1,68 @@
|
||||
"""Network device create/update attaches a primary IP Communication.
|
||||
|
||||
Classic network devices carry their IP in the communications table, and the
|
||||
legacy import needs an HTTP path to set it. The network create/update routes
|
||||
accept an `ipaddress` and materialize a primary Communication (mirroring the
|
||||
printer route); GET surfaces it back.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def network_setup(db):
|
||||
from shopdb.core.models import AssetType, CommunicationType
|
||||
db.session.add(AssetType(assettype='network_device', pluginname='network',
|
||||
tablename='networkdevices', description='Network devices'))
|
||||
db.session.add(CommunicationType(comtype='IP', description='IP'))
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def test_create_network_device_with_ipaddress(client, db, auth_headers, network_setup):
|
||||
"""POST /api/network with an ipaddress creates a primary Communication and
|
||||
echoes the IP back."""
|
||||
from shopdb.core.models import Communication
|
||||
resp = client.post('/api/network', json={
|
||||
'assetnumber': 'SW-CORE-01', 'name': 'Core switch',
|
||||
'hostname': 'sw-core-01', 'ipaddress': '10.129.22.101',
|
||||
}, headers=auth_headers)
|
||||
assert resp.status_code == 201, resp.get_json()
|
||||
data = resp.get_json()['data']
|
||||
assert data['ipaddress'] == '10.129.22.101'
|
||||
|
||||
assetid = data['assetid']
|
||||
comm = Communication.query.filter_by(assetid=assetid, isprimary=True).first()
|
||||
assert comm is not None and comm.ipaddress == '10.129.22.101'
|
||||
|
||||
# GET surfaces it too.
|
||||
got = client.get(f"/api/network/{data['networkdevice']['networkdeviceid']}",
|
||||
headers=auth_headers)
|
||||
assert got.get_json()['data']['ipaddress'] == '10.129.22.101'
|
||||
|
||||
|
||||
def test_update_network_device_ipaddress_upserts(client, db, auth_headers, network_setup):
|
||||
"""PUT changes the IP in place without creating a second Communication."""
|
||||
from shopdb.core.models import Communication
|
||||
created = client.post('/api/network', json={
|
||||
'assetnumber': 'AP-01', 'name': 'AP', 'ipaddress': '10.0.0.5',
|
||||
}, headers=auth_headers).get_json()['data']
|
||||
devid = created['networkdevice']['networkdeviceid']
|
||||
|
||||
updated = client.put(f'/api/network/{devid}', json={'ipaddress': '10.0.0.9'},
|
||||
headers=auth_headers)
|
||||
assert updated.status_code == 200, updated.get_json()
|
||||
assert updated.get_json()['data']['ipaddress'] == '10.0.0.9'
|
||||
|
||||
comms = Communication.query.filter_by(assetid=created['assetid']).all()
|
||||
assert len(comms) == 1 and comms[0].ipaddress == '10.0.0.9'
|
||||
|
||||
|
||||
def test_seed_reference_data_creates_ip_commtype(app, db):
|
||||
"""The reference-data seed now creates the IP communication type the asset
|
||||
import depends on."""
|
||||
from shopdb.core.models import CommunicationType
|
||||
runner = app.test_cli_runner()
|
||||
assert runner.invoke(args=['seed', 'reference-data']).exit_code in (0, None)
|
||||
with app.app_context():
|
||||
assert CommunicationType.query.filter_by(comtype='IP').first() is not None
|
||||
assert CommunicationType.query.count() >= 8
|
||||
69
tests/test_plugins/test_pc_default_printer.py
Normal file
69
tests/test_plugins/test_pc_default_printer.py
Normal file
@@ -0,0 +1,69 @@
|
||||
"""Tests for the per-PC default-printer endpoint (/api/printers/pc-default).
|
||||
|
||||
Parity with classic apipcdefaultprinter.asp: the signed printer-installer EXE
|
||||
preselects a PC's default printer by machine (asset) number. The link is a
|
||||
`defaultprinter` asset relationship (PC asset -> printer asset).
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from shopdb.core.models import Asset, AssetType, AssetRelationship, RelationshipType
|
||||
from plugins.printers.models import Printer
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def scene(db):
|
||||
"""A PC asset, a printer asset, and the defaultprinter relationship type."""
|
||||
pc_type = AssetType(assettype='computer', pluginname='computers', tablename='computers')
|
||||
pr_type = AssetType(assettype='printer', pluginname='printers', tablename='printers')
|
||||
dp_type = RelationshipType(relationshiptype='defaultprinter', description='PC to default printer')
|
||||
db.session.add_all([pc_type, pr_type, dp_type])
|
||||
db.session.flush()
|
||||
|
||||
pc = Asset(assetnumber='3210', name='CSF04 PC', assettypeid=pc_type.assettypeid, isactive=True)
|
||||
pr_asset = Asset(assetnumber='PRN-01', name='Materials HP', assettypeid=pr_type.assettypeid, isactive=True)
|
||||
db.session.add_all([pc, pr_asset])
|
||||
db.session.flush()
|
||||
|
||||
printer = Printer(assetid=pr_asset.assetid, windowsname='HP-CSF04-Materials')
|
||||
db.session.add(printer)
|
||||
db.session.commit()
|
||||
|
||||
return {'pc': pc, 'printer_asset': pr_asset, 'printer': printer, 'dp_type': dp_type}
|
||||
|
||||
|
||||
def _link_default(db, pc, printer_asset, dp_type):
|
||||
db.session.add(AssetRelationship(
|
||||
sourceassetid=pc.assetid,
|
||||
targetassetid=printer_asset.assetid,
|
||||
relationshiptypeid=dp_type.relationshiptypeid,
|
||||
))
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def test_returns_default_printer_for_pc(client, db, scene):
|
||||
_link_default(db, scene['pc'], scene['printer_asset'], scene['dp_type'])
|
||||
|
||||
resp = client.get('/api/printers/pc-default?machine=3210')
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()['data']
|
||||
assert data['printerid'] == scene['printer'].printerid
|
||||
assert data['windowsname'] == 'HP-CSF04-Materials'
|
||||
|
||||
|
||||
def test_pc_without_default_returns_empty(client, db, scene):
|
||||
resp = client.get('/api/printers/pc-default?machine=3210')
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json()['data'] == {}
|
||||
|
||||
|
||||
def test_unknown_machine_returns_empty(client, db, scene):
|
||||
resp = client.get('/api/printers/pc-default?machine=NOPE')
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json()['data'] == {}
|
||||
|
||||
|
||||
def test_missing_machine_param_returns_empty(client, db, scene):
|
||||
resp = client.get('/api/printers/pc-default')
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json()['data'] == {}
|
||||
48
tests/test_plugins/test_printer_install_list.py
Normal file
48
tests/test_plugins/test_printer_install_list.py
Normal file
@@ -0,0 +1,48 @@
|
||||
"""Tests for the printer installer endpoint (/api/printers/install-list).
|
||||
|
||||
The signed printer installer EXE pulls network-printer data + floor-map
|
||||
positions from here (shopfloor 2.0 cannot run unsigned .bat maps).
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def printer_assettype(db):
|
||||
from shopdb.core.models import AssetType
|
||||
at = AssetType(assettype='printer', pluginname='printers',
|
||||
tablename='printers', description='Printers')
|
||||
db.session.add(at)
|
||||
db.session.commit()
|
||||
return at
|
||||
|
||||
|
||||
def test_install_list_returns_network_printer_with_map(client, db, auth_headers,
|
||||
printer_assettype):
|
||||
"""A network printer appears with flat installer fields incl map position."""
|
||||
created = client.post('/api/printers', json={
|
||||
'assetnumber': 'CSF01-Materials-HP',
|
||||
'hostname': 'wjprn01',
|
||||
'installpath': r'\\srv\drivers\hp',
|
||||
'mapx': 120, 'mapy': 240,
|
||||
}, headers=auth_headers)
|
||||
assert created.status_code == 201, created.get_json()
|
||||
|
||||
resp = client.get('/api/printers/install-list', headers=auth_headers)
|
||||
assert resp.status_code == 200
|
||||
rows = resp.get_json()['data']
|
||||
row = next((r for r in rows if r['machinenumber'] == 'CSF01-Materials-HP'), None)
|
||||
assert row is not None
|
||||
assert row['hostname'] == 'wjprn01'
|
||||
assert row['installpath'] == r'\\srv\drivers\hp'
|
||||
assert row['mapx'] == 120 and row['mapy'] == 240
|
||||
|
||||
|
||||
def test_install_list_excludes_usb_only_printer(client, db, auth_headers,
|
||||
printer_assettype):
|
||||
"""A printer with no hostname and no network IP is excluded."""
|
||||
client.post('/api/printers', json={'assetnumber': 'USB-LABELER'},
|
||||
headers=auth_headers)
|
||||
resp = client.get('/api/printers/install-list', headers=auth_headers)
|
||||
machinenumbers = [r['machinenumber'] for r in resp.get_json()['data']]
|
||||
assert 'USB-LABELER' not in machinenumbers
|
||||
72
tests/test_plugins/test_shopfloor_feed.py
Normal file
72
tests/test_plugins/test_shopfloor_feed.py
Normal file
@@ -0,0 +1,72 @@
|
||||
"""Tests for the shopfloor TV feed (/api/notifications/shopfloor).
|
||||
|
||||
Since the notification-type display rework, per-employee fan-out is driven by
|
||||
the type's splitperemployee column (seeded true for Recognition/Training):
|
||||
a note naming several comma-joined SSOs yields one card per employee; types
|
||||
without the flag stay a single card.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from plugins.notifications.models import Notification, NotificationType
|
||||
|
||||
|
||||
def _make_type(db, typename, typecolor, splitperemployee=False):
|
||||
t = NotificationType(typename=typename, typecolor=typecolor,
|
||||
splitperemployee=splitperemployee, isactive=True)
|
||||
db.session.add(t)
|
||||
db.session.commit()
|
||||
return t
|
||||
|
||||
|
||||
def _make_shopfloor_note(db, ntype, employeesso, employeename):
|
||||
n = Notification(
|
||||
notificationtypeid=ntype.notificationtypeid,
|
||||
notification='msg',
|
||||
businessunitid=None,
|
||||
isactive=True,
|
||||
isshopfloor=True,
|
||||
employeesso=employeesso,
|
||||
employeename=employeename,
|
||||
)
|
||||
db.session.add(n)
|
||||
db.session.commit()
|
||||
return n
|
||||
|
||||
|
||||
@pytest.mark.parametrize('typecolor', ['recognition', 'training'])
|
||||
def test_multi_employee_split_into_one_card_each(client, db, typecolor):
|
||||
"""A split-flagged note with two SSOs yields two current cards."""
|
||||
ntype = _make_type(db, typecolor.capitalize(), typecolor, splitperemployee=True)
|
||||
_make_shopfloor_note(db, ntype, '111,222', 'Alice, Bob')
|
||||
|
||||
resp = client.get('/api/notifications/shopfloor')
|
||||
assert resp.status_code == 200
|
||||
current = resp.get_json()['data']['current']
|
||||
assert len(current) == 2
|
||||
assert {c['employeesso'] for c in current} == {'111', '222'}
|
||||
assert {c['employeename'] for c in current} == {'Alice', 'Bob'}
|
||||
|
||||
|
||||
def test_non_split_type_stays_single_card(client, db):
|
||||
"""A type without splitperemployee is not fanned out, even with many SSOs."""
|
||||
ntype = _make_type(db, 'Awareness', 'info')
|
||||
_make_shopfloor_note(db, ntype, '111,222', 'Alice, Bob')
|
||||
|
||||
resp = client.get('/api/notifications/shopfloor')
|
||||
assert resp.status_code == 200
|
||||
current = resp.get_json()['data']['current']
|
||||
assert len(current) == 1
|
||||
assert current[0]['employeesso'] == '111,222'
|
||||
|
||||
|
||||
def test_single_employee_recognition_stays_single_card(client, db):
|
||||
"""One SSO produces one card (no spurious split on a lone employee)."""
|
||||
ntype = _make_type(db, 'Recognition', 'recognition', splitperemployee=True)
|
||||
_make_shopfloor_note(db, ntype, '111', 'Alice')
|
||||
|
||||
resp = client.get('/api/notifications/shopfloor')
|
||||
assert resp.status_code == 200
|
||||
current = resp.get_json()['data']['current']
|
||||
assert len(current) == 1
|
||||
assert current[0]['employeesso'] == '111'
|
||||
Reference in New Issue
Block a user