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:
2026-07-14 07:58:51 -04:00
commit a9d0dfff4a
599 changed files with 88557 additions and 0 deletions

View File

View 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

View 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'

View 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']

View 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

View 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'

View 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())

View 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']

View 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

View 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

View 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

View 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}'

View 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']

View 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') == []

View 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

View 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'] == {}

View 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

View 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'