Build GE-Enforce manifest-store plugin (P0/P1): model, importer, parity gate
First execution phases of docs/proposals/ge-enforce-plugin.md. The GE-Enforce manifest becomes shopdb data. P0 scaffold: new geenforce plugin (api_prefix /api/geenforce, default_enabled false, core_version >=0.7.0). Registered in PLUGIN_TABLE_OWNERS (ADR-008); its 0001 baseline really creates the tables. P1a model: one wide manifestentries table + entrytype discriminator (not STI, not JSON blob), manifestscopes (UNIQUE scopename+phase), the three multi-value filter child tables, inusechecks + processes, immutable manifestpublishedversions (frozen rendered JSON), manifestpayloads (inline, capped), pctypealiases (mirror of the engine lib's alias graph). regvalue stored as its raw JSON literal so DWord typing survives. P1c importer + exporter: parse common + gea-shopfloor-* + preinstall.json into draft rows and rebuild the JSON verbatim from rows in sortorder. P1d parity harness (GATE A): filters.py mirrors the engine's four filter functions + alias graph; parity.py proves import+export is behaviorally lossless (field-identical + same-entries-fire across 18 machine-profile fixtures) WITHOUT byte-diffing. Verified PASS against all 11 real reference manifests (64 entries) and a synthetic site-neutral fixture covering every type/filter (the CI gate). First slice (gea-shopfloor-cmm shape): service layer (import/publish/rollback/ export-to-share), CLI (parity, import-share, publish, export-share), and the client endpoint GET /api/geenforce/manifest serving the current published snapshot (never the draft) with ETag/304. Split permissions geenforce.manage/publish/fetch. Tests prove import->publish->serve, draft edits never change served bytes, publish+rollback, and auth (401 unauth/wrong-scope). Contract 0.11.0: added service_token_authorized(scope) to shopdb.api so plugin service endpoints authorize a scoped managed token without importing core token internals. Documented in PLUGIN-HOOKS.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
152
tests/test_plugins/test_geenforce_manifest.py
Normal file
152
tests/test_plugins/test_geenforce_manifest.py
Normal file
@@ -0,0 +1,152 @@
|
||||
"""GE-Enforce first slice: import a scope, publish it, serve it to a client.
|
||||
|
||||
Exercises the vertical the plan calls the first slice (via a small synthetic
|
||||
scope rather than the real gea-shopfloor-cmm): draft import -> publish ->
|
||||
GET /api/geenforce/manifest with a geenforce.fetch service token; ETag/304;
|
||||
draft edits never change the served bytes; publish + rollback change them.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from plugins.geenforce.models import ManifestScope
|
||||
from plugins.geenforce import service
|
||||
|
||||
|
||||
SCOPE = {
|
||||
'Version': '2.6',
|
||||
'Applications': [
|
||||
{'Name': 'Alpha', 'Type': 'MSI', 'Installer': 'apps/alpha.msi',
|
||||
'DetectionMethod': 'Registry', 'DetectionPath': 'HKLM:\\SOFTWARE\\Alpha'},
|
||||
{'Name': 'Beta', 'Type': 'PS1', 'Script': 'scripts/beta.ps1',
|
||||
'DetectionMethod': 'Always'},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _seed_and_publish(app, scopename='gea-shopfloor-cmm', manifest=None):
|
||||
with app.app_context():
|
||||
service.replace_scope_draft(scopename, 'runtime', manifest or SCOPE)
|
||||
service.publish_scope(scopename, 'runtime', notes='initial')
|
||||
service.db.session.commit()
|
||||
|
||||
|
||||
def _mint_fetch_token(client, auth_headers):
|
||||
resp = client.post('/api/apitokens',
|
||||
json={'name': 'geenforce svc', 'scopes': ['geenforce.fetch']},
|
||||
headers=auth_headers)
|
||||
assert resp.status_code == 201, resp.get_json()
|
||||
return resp.get_json()['data']['secret']
|
||||
|
||||
|
||||
def test_import_publish_serve(client, db, app, auth_headers):
|
||||
_seed_and_publish(app)
|
||||
secret = _mint_fetch_token(client, auth_headers)
|
||||
|
||||
resp = client.get('/api/geenforce/manifest?pctype=gea-shopfloor-cmm',
|
||||
headers={'X-API-Key': secret})
|
||||
assert resp.status_code == 200, resp.data
|
||||
body = json.loads(resp.data)
|
||||
assert [e['Name'] for e in body['Applications']] == ['Alpha', 'Beta']
|
||||
assert resp.headers.get('ETag')
|
||||
assert resp.headers.get('X-Manifest-Version') == '1'
|
||||
|
||||
|
||||
def test_etag_304(client, db, app, auth_headers):
|
||||
_seed_and_publish(app)
|
||||
secret = _mint_fetch_token(client, auth_headers)
|
||||
first = client.get('/api/geenforce/manifest?pctype=gea-shopfloor-cmm',
|
||||
headers={'X-API-Key': secret})
|
||||
etag = first.headers['ETag']
|
||||
again = client.get('/api/geenforce/manifest?pctype=gea-shopfloor-cmm',
|
||||
headers={'X-API-Key': secret, 'If-None-Match': etag})
|
||||
assert again.status_code == 304
|
||||
|
||||
|
||||
def test_draft_edit_does_not_change_served_bytes(client, db, app, auth_headers):
|
||||
_seed_and_publish(app)
|
||||
secret = _mint_fetch_token(client, auth_headers)
|
||||
before = client.get('/api/geenforce/manifest?pctype=gea-shopfloor-cmm',
|
||||
headers={'X-API-Key': secret}).data
|
||||
|
||||
# Edit the DRAFT (add an entry) but do NOT publish.
|
||||
changed = {'Version': '2.6', 'Applications': SCOPE['Applications'] + [
|
||||
{'Name': 'Gamma', 'Type': 'PS1', 'Script': 'scripts/gamma.ps1'}]}
|
||||
with app.app_context():
|
||||
service.replace_scope_draft('gea-shopfloor-cmm', 'runtime', changed)
|
||||
service.db.session.commit()
|
||||
|
||||
after = client.get('/api/geenforce/manifest?pctype=gea-shopfloor-cmm',
|
||||
headers={'X-API-Key': secret}).data
|
||||
assert before == after # served bytes come from the published snapshot only
|
||||
|
||||
|
||||
def test_publish_then_rollback(client, db, app, auth_headers):
|
||||
_seed_and_publish(app)
|
||||
secret = _mint_fetch_token(client, auth_headers)
|
||||
v1 = client.get('/api/geenforce/manifest?pctype=gea-shopfloor-cmm',
|
||||
headers={'X-API-Key': secret}).data
|
||||
|
||||
changed = {'Version': '2.6', 'Applications': SCOPE['Applications'] + [
|
||||
{'Name': 'Gamma', 'Type': 'PS1', 'Script': 'scripts/gamma.ps1'}]}
|
||||
with app.app_context():
|
||||
service.replace_scope_draft('gea-shopfloor-cmm', 'runtime', changed)
|
||||
service.publish_scope('gea-shopfloor-cmm', 'runtime', notes='add gamma')
|
||||
service.db.session.commit()
|
||||
|
||||
v2 = client.get('/api/geenforce/manifest?pctype=gea-shopfloor-cmm',
|
||||
headers={'X-API-Key': secret})
|
||||
assert v2.headers['X-Manifest-Version'] == '2'
|
||||
assert b'Gamma' in v2.data
|
||||
|
||||
with app.app_context():
|
||||
service.rollback_scope('gea-shopfloor-cmm', 'runtime', 1)
|
||||
service.db.session.commit()
|
||||
|
||||
rolled = client.get('/api/geenforce/manifest?pctype=gea-shopfloor-cmm',
|
||||
headers={'X-API-Key': secret})
|
||||
assert rolled.data == v1 # byte-identical: published snapshots are frozen text
|
||||
|
||||
|
||||
def test_unauthenticated_rejected(client, db, app):
|
||||
_seed_and_publish(app)
|
||||
resp = client.get('/api/geenforce/manifest?pctype=gea-shopfloor-cmm')
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
def test_wrong_scope_rejected(client, db, app, auth_headers):
|
||||
_seed_and_publish(app)
|
||||
resp = client.post('/api/apitokens',
|
||||
json={'name': 'wrong', 'scopes': ['collector.ingest']},
|
||||
headers=auth_headers)
|
||||
secret = resp.get_json()['data']['secret']
|
||||
manifest = client.get('/api/geenforce/manifest?pctype=gea-shopfloor-cmm',
|
||||
headers={'X-API-Key': secret})
|
||||
assert manifest.status_code == 401
|
||||
|
||||
|
||||
def test_unpublished_scope_404(client, db, app, auth_headers):
|
||||
with app.app_context():
|
||||
service.replace_scope_draft('gea-shopfloor-keyence', 'runtime', SCOPE)
|
||||
service.db.session.commit() # draft only, never published
|
||||
secret = _mint_fetch_token(client, auth_headers)
|
||||
resp = client.get('/api/geenforce/manifest?pctype=gea-shopfloor-keyence',
|
||||
headers={'X-API-Key': secret})
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_admin_list_and_preview(client, db, app, auth_headers):
|
||||
_seed_and_publish(app)
|
||||
listing = client.get('/api/geenforce/scopes', headers=auth_headers)
|
||||
assert listing.status_code == 200
|
||||
scopes = listing.get_json()['data']
|
||||
cmm = next(s for s in scopes if s['scopename'] == 'gea-shopfloor-cmm')
|
||||
assert cmm['entrycount'] == 2
|
||||
assert cmm['publishedversion'] == 1
|
||||
|
||||
preview = client.get(f"/api/geenforce/scopes/{cmm['scopeid']}/preview",
|
||||
headers=auth_headers)
|
||||
assert preview.status_code == 200
|
||||
assert [e['Name'] for e in preview.get_json()['data']['manifest']['Applications']] \
|
||||
== ['Alpha', 'Beta']
|
||||
142
tests/test_plugins/test_geenforce_parity.py
Normal file
142
tests/test_plugins/test_geenforce_parity.py
Normal file
@@ -0,0 +1,142 @@
|
||||
"""GE-Enforce P1 parity gate (Gate A): import + export is behaviorally lossless.
|
||||
|
||||
Runs the DB-free parity harness (parse -> in-memory rows -> render -> compare)
|
||||
over a synthetic, site-neutral manifest that exercises every entry type,
|
||||
detection method, targeting filter, RegValue typing, InUseCheck, and preinstall
|
||||
flag. The synthetic fixture is what CI gates on (no real site data is vendored
|
||||
into the framework repo). If the real GE-Enforce reference share is present
|
||||
(dev), it is also checked.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from plugins.geenforce.parity import run_parity, load_fixtures
|
||||
from plugins.geenforce.importer import discover_share, load_manifest_file
|
||||
|
||||
|
||||
# A site-neutral manifest covering the whole schema surface.
|
||||
SYNTHETIC = {
|
||||
'Version': '2.6',
|
||||
'_comment': 'Synthetic parity fixture (not a real site manifest).',
|
||||
'Applications': [
|
||||
{
|
||||
'_comment': 'MSI with Registry detection, version gate, InUseCheck.',
|
||||
'Name': 'Sample MSI', 'Type': 'MSI',
|
||||
'Installer': 'apps/sample.msi',
|
||||
'InstallArgs': '/qn /norestart ALLUSERS=1',
|
||||
'_CmmVersion': '2019',
|
||||
'DetectionMethod': 'Registry',
|
||||
'DetectionPath': 'HKLM:\\SOFTWARE\\Sample',
|
||||
'DetectionName': 'DisplayVersion', 'DetectionValue': '1.2.3.4',
|
||||
'InUseCheck': {
|
||||
'Behavior': 'CloseAndReopen',
|
||||
'Processes': [
|
||||
{'Name': 'sample', 'ExePath': 'C:\\sample.exe',
|
||||
'GracefulCloseTimeoutSec': 15},
|
||||
{'Name': 'other'},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
'Name': 'Sample EXE', 'Type': 'EXE', 'Installer': 'apps/sample.exe',
|
||||
'InstallArgs': '/quiet', 'WaitTimeoutSec': 60,
|
||||
'DetectionMethod': 'FileVersion',
|
||||
'DetectionPath': 'C:\\sample.exe', 'DetectionValue': '1.0.0.0',
|
||||
},
|
||||
{
|
||||
'Name': 'Sample PS1', 'Type': 'PS1', 'Script': 'scripts/run.ps1',
|
||||
'Args': '-Force', 'DetectionMethod': 'Always',
|
||||
},
|
||||
{
|
||||
'Name': 'Sample File', 'Type': 'File', 'Source': 'configs/app.json',
|
||||
'Destination': 'C:\\ProgramData\\app.json',
|
||||
'DetectionMethod': 'Hash', 'DetectionPath': 'C:\\ProgramData\\app.json',
|
||||
'DetectionValue': 'a' * 64,
|
||||
'PCTypes': ['gea-shopfloor-collections', 'gea-shopfloor-cmm'],
|
||||
},
|
||||
{
|
||||
'Name': 'Sample Registry DWord', 'Type': 'Registry',
|
||||
'RegPath': 'HKLM:\\SOFTWARE\\Sample', 'RegName': 'Enabled',
|
||||
'RegValue': 1, 'RegType': 'DWord',
|
||||
'DetectionMethod': 'ValueMatches',
|
||||
'DetectionPath': 'HKLM:\\SOFTWARE\\Sample', 'DetectionName': 'Enabled',
|
||||
},
|
||||
{
|
||||
'Name': 'Sample Registry String', 'Type': 'Registry',
|
||||
'RegPath': 'HKLM:\\SOFTWARE\\Sample', 'RegName': 'Mode',
|
||||
'RegValue': 'on', 'RegType': 'String',
|
||||
},
|
||||
{
|
||||
'Name': 'Sample INF', 'Type': 'INF', 'Installer': 'configs/driver.inf',
|
||||
'DetectionMethod': 'pnputil', 'DetectionPattern': 'SampleDriver',
|
||||
},
|
||||
{
|
||||
'Name': 'Sample bay-gated', 'Type': 'CMD', 'Installer': 'scripts/bay.cmd',
|
||||
'TargetMachineNumbers': ['3201', '3202'],
|
||||
},
|
||||
{
|
||||
'Name': 'Sample host-gated', 'Type': 'BAT', 'Installer': 'scripts/host.bat',
|
||||
'TargetHostnames': ['WJS-*', 'WJPC0615'],
|
||||
},
|
||||
{
|
||||
'Name': 'Sample always-installs (no detection)', 'Type': 'PS1',
|
||||
'Script': 'scripts/every.ps1',
|
||||
},
|
||||
{
|
||||
'Name': 'Sample preinstall-flagged', 'Type': 'EXE',
|
||||
'Installer': 'apps/pre.exe', 'PreEnrollment': True,
|
||||
'PCTypesStrict': True, 'KillAfterDetection': True,
|
||||
'PCTypes': ['gea-shopfloor-nocollections'],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
REFERENCE_SHARE = '/home/camp/pxe-images/tsgwp00525-v2/shared/dt/shopfloor'
|
||||
REFERENCE_PREINSTALL = '/home/camp/projects/pxe/playbook/preinstall/preinstall.json'
|
||||
|
||||
|
||||
def test_synthetic_manifest_round_trips_losslessly():
|
||||
"""The synthetic manifest imports + exports with full behavioral parity."""
|
||||
results, ok = run_parity([('synthetic', 'runtime', SYNTHETIC)])
|
||||
result = results[0]
|
||||
assert result['entries_identical'] == result['entries_total'], result['firstdiff']
|
||||
assert result['profiles_same'] == result['profiles_total'], result['firstdiff']
|
||||
assert ok
|
||||
|
||||
|
||||
def test_regvalue_numeric_type_preserved():
|
||||
"""A DWord RegValue of 1 round-trips as int 1, not the string '1'."""
|
||||
from plugins.geenforce.importer import build_scope
|
||||
from plugins.geenforce.serializer import scope_to_manifest
|
||||
|
||||
rebuilt = scope_to_manifest(build_scope('synthetic', 'runtime', SYNTHETIC))
|
||||
dword = next(e for e in rebuilt['Applications']
|
||||
if e['Name'] == 'Sample Registry DWord')
|
||||
assert dword['RegValue'] == 1
|
||||
assert isinstance(dword['RegValue'], int)
|
||||
|
||||
|
||||
@pytest.mark.skipif(not os.path.isdir(REFERENCE_SHARE),
|
||||
reason='GE-Enforce reference share not present')
|
||||
def test_reference_share_round_trips_losslessly():
|
||||
"""Every real on-share manifest round-trips (the live Gate A)."""
|
||||
manifests = list(discover_share(REFERENCE_SHARE))
|
||||
if os.path.isfile(REFERENCE_PREINSTALL):
|
||||
manifests.append(('preinstall', 'preinstall',
|
||||
load_manifest_file(REFERENCE_PREINSTALL)))
|
||||
results, ok = run_parity(manifests)
|
||||
failed = [r for r in results if not r['passed']]
|
||||
assert ok, f"parity failures: {[(r['scopename'], r['firstdiff']) for r in failed]}"
|
||||
|
||||
|
||||
def test_fixtures_cover_every_pctype():
|
||||
"""The machine-profile fixtures include one of each imaging pctype."""
|
||||
labels = {f['pctype'] for f in load_fixtures()}
|
||||
for pctype in ('gea-shopfloor-collections', 'gea-shopfloor-nocollections',
|
||||
'gea-shopfloor-common', 'gea-shopfloor-cmm',
|
||||
'gea-shopfloor-genspect', 'gea-shopfloor-heattreat',
|
||||
'gea-shopfloor-waxtrace', 'gea-shopfloor-partmarker',
|
||||
'gea-shopfloor-display'):
|
||||
assert pctype in labels
|
||||
Reference in New Issue
Block a user