A deep Fable review of the real manifest corpus (READ-ONLY reference) showed the manifests are an ENFORCEMENT PROGRAM, not an application inventory, and that auto-seeding the Applications catalog from entry Type + Name was wrong: - The catalog ALREADY tracks these apps from the classic-shopdb migration, with version histories (PC - DMIS, UDC x11 versions, eMX / eDNC, CLM, CSF, Oracle Database, FormTracePak). Seeding from manifest labels created DUPLICATES under different names (PC-DMIS 2016 vs PC - DMIS; eDNC (bundles NTLARS) vs eMX / eDNC; OpenText HostExplorer ShopFloor vs CSF). It also misclassified config drops (eMxInfo.txt) as apps and could never match a PC's reported ARP name. So the seed-applications command + service are removed. Properly linking manifest entries to the EXISTING catalog is a curated feature, not label-scraping. Two REAL bugs the review found are fixed and kept: - Report status (R4): every healthy cycle runs Always/no-detection scripts the engine counts as "installed", so keying self-heal off installed>0 marked the common scope selfhealed forever and made 'ok' unreachable. Status now derives from explicit per-entry self-heal flags only; the stored flag no longer infers from action=='installed'; the client kit doc reflects it. - PCTypesStrict (R5): the runtime engine has no strict handling (preinstall runner only). filters.matches_pctype now applies strict only when phase == 'preinstall'; simulate + parity thread the scope phase through; the strict test uses a preinstall scope. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
232 lines
11 KiB
Python
232 lines
11 KiB
Python
"""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_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
|