Fix defects found in session review of GE-Enforce plugin
All checks were successful
CI / backend (push) Successful in 1m43s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 8s

Consolidated fixes from a three-dimension adversarial review.

Data-loss (HIGH): the manifest entry editor stripped fields the form did not
expose, because PUT /entries is a full reset-then-apply. The form now captures
everything - InUseCheck processes as structured name/ExePath/timeout rows (not
just names), LogFile, and the three preinstall flags as checkboxes; the dead
payload-source control (never wired) is removed. New regression test proves an
edit preserves ExePath/timeout/LogFile/PreEnrollment/PCTypesStrict.

Update-entry crash (found by that regression test): replacing an entry's
one-to-one InUseCheck (unique entryid) collided with the old row mid-flush ->
IntegrityError -> 400. update_entry now frees the old InUseCheck (delete+flush)
before populate re-inserts it.

Export truncation (MEDIUM): export_scope_to_share used a plain truncating open,
so a failed/partial write left the live on-share manifest (every PC reads it)
empty. Now writes a temp file in the same dir and os.replace() atomically.

Report dedup case bug (MEDIUM, confirmed by scratch test): the iscurrent demote
matched hostname case-sensitively while the read path uses ilike, so a PC
reporting different casing left two iscurrent rows and double-counted. Demote is
now case-insensitive; regression test added.

Simulator fidelity (MEDIUM): PCTypesStrict was captured but ignored by the
filter mirror, so the simulator wrongly matched a collections-only strict entry
to a nocollections PC via the shared Standard alias group. matches_pctype now
honors PCTypesStrict (disables alias expansion); test added.

Hardening: removed the dead/unscoped GEENFORCE_API_KEY env fallback (never wired
into config; tokens are the only path); create/update entry return 400 on a
duplicate Name instead of 500; parity now asserts scope-level Version/Site; a
new test guards real-manifest field lengths against column limits (the DB-free
parity harness can't see truncation); error handling added to the previously
unguarded editor + reports API calls.

Full suite green; naming + frontend build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
cproudlock
2026-07-12 20:46:09 -04:00
parent d894f054ac
commit 0dcd186820
9 changed files with 314 additions and 62 deletions

View File

@@ -1,6 +1,7 @@
<template>
<div class="enforcement-reports">
<div class="page-header"><h1>Enforcement Reports</h1></div>
<div v-if="error" class="alert-error">{{ error }}</div>
<p class="intro">
Latest GE-Enforce result reported by each PC. "Received" means the PC picked
up the current published manifest; status shows self-heal and failures.
@@ -75,6 +76,7 @@ import api from '../../api'
const reports = ref([])
const detail = ref(null)
const error = ref('')
const filterHost = ref('')
const filterScope = ref('')
@@ -84,11 +86,16 @@ async function load() {
const params = {}
if (filterHost.value) params.hostname = filterHost.value
if (filterScope.value) params.scopename = filterScope.value
try {
reports.value = payload(await api.get('/geenforce/reports', { params }))
error.value = ''
} catch (e) { error.value = 'Failed to load reports' }
}
function clearFilters() { filterHost.value = ''; filterScope.value = ''; load() }
async function openDetail(reportid) {
try {
detail.value = payload(await api.get(`/geenforce/reports/${reportid}`))
} catch (e) { error.value = 'Failed to load report detail' }
}
function statusClass(status) {
@@ -106,6 +113,8 @@ load()
<style scoped>
.enforcement-reports { max-width: 1100px; }
.intro { color: var(--text-light); margin: 0 0 1rem 0; }
.alert-error { background: var(--danger); color: white; padding: 0.5rem 0.75rem;
border-radius: 4px; margin-bottom: 1rem; }
.filters { display: flex; gap: 0.5rem; margin-bottom: 1rem; }
.dim { color: var(--text-light); }
.fail-count { color: var(--danger); font-weight: 600; }

View File

@@ -215,26 +215,39 @@
<label>CMM version gate<input v-model="entryForm._CmmVersion" placeholder="2016 / 2019 / 2026" /></label>
<label>Wait timeout (sec)<input type="number" v-model.number="entryForm.WaitTimeoutSec" /></label>
<label>Payload source
<select v-model="entryForm.payloadsource">
<option value="smb">smb</option>
<option value="http">http</option>
<option value="inline">inline</option>
</select>
</label>
<label>Log file<input v-model="entryForm.LogFile" /></label>
<label class="full">Comment<textarea v-model="entryForm._comment" rows="2" /></label>
<details class="full advanced">
<summary>Advanced (not yet enforced by the engine)</summary>
<label>Apply mode<input v-model="entryForm.ApplyMode" placeholder="Nightly / Immediate" /></label>
<label>Update window<input v-model="entryForm.UpdateWindow" placeholder="HH:MM-HH:MM" /></label>
<label>InUseCheck behavior
<details class="full advanced" open>
<summary>In-use handling + preinstall flags</summary>
<label>Close running app before install (InUseCheck)
<select v-model="entryForm.inuseBehavior">
<option value="">(none)</option>
<option v-for="b in INUSE_BEHAVIORS" :key="b" :value="b">{{ b }}</option>
</select>
</label>
<label>InUseCheck processes (comma of names)<input v-model="entryForm.inuseProcesses" /></label>
<div class="full" v-if="entryForm.inuseBehavior">
<div class="proc-head">Processes to close
<button type="button" class="btn btn-small" @click="addProcess">+ process</button>
</div>
<div v-for="(proc, i) in entryForm.inuseProcesses" :key="i" class="proc-row">
<input v-model="proc.name" placeholder="Name (no .exe)" />
<input v-model="proc.exepath" placeholder="ExePath (optional)" />
<input type="number" v-model.number="proc.timeout" placeholder="timeout s" />
<button type="button" class="btn-move" @click="removeProcess(i)">x</button>
</div>
</div>
<div class="full preinstall-flags">
<span class="flags-label">Preinstall flags (preinstall phase only):</span>
<label class="inline"><input type="checkbox" v-model="entryForm.PreEnrollment" /> PreEnrollment</label>
<label class="inline"><input type="checkbox" v-model="entryForm.KillAfterDetection" /> KillAfterDetection</label>
<label class="inline"><input type="checkbox" v-model="entryForm.PCTypesStrict" /> PCTypesStrict</label>
</div>
</details>
<details class="full advanced">
<summary>Apply mode / update window (not yet enforced by the engine)</summary>
<label>Apply mode<input v-model="entryForm.ApplyMode" placeholder="Nightly / Immediate" /></label>
<label>Update window<input v-model="entryForm.UpdateWindow" placeholder="HH:MM-HH:MM" /></label>
</details>
</div>
<div class="modal-actions">
@@ -347,16 +360,19 @@ async function saveScope() {
}
async function deleteScope() {
if (!confirm(`Delete ${detail.value.scopename}? This removes its manifest.`)) return
try {
await api.delete(`/geenforce/scopes/${detail.value.scopeid}`)
detail.value = null
selectedId.value = null
await loadScopes()
} catch (e) { error.value = 'Delete failed' }
}
// -- entries --
function blankEntry() {
return { Type: 'MSI', DetectionMethod: '', RegType: 'String',
payloadsource: 'smb', inuseBehavior: '', inuseProcesses: '' }
inuseBehavior: '', inuseProcesses: [],
PreEnrollment: false, KillAfterDetection: false, PCTypesStrict: false }
}
function openNewEntry() {
entryForm.value = blankEntry()
@@ -369,12 +385,24 @@ function openEditEntry(entry) {
form.TargetMachineNumbers = (entry.TargetMachineNumbers || []).join(', ')
form.RegValue = entry.RegValue !== undefined ? String(entry.RegValue) : ''
form.DetectionMethod = entry.DetectionMethod || ''
form.payloadsource = entry.payloadsource || 'smb'
form.inuseBehavior = entry.InUseCheck?.Behavior || ''
form.inuseProcesses = (entry.InUseCheck?.Processes || []).map(p => p.Name).join(', ')
// Structured processes so ExePath + timeout survive an edit (not just Name).
form.inuseProcesses = (entry.InUseCheck?.Processes || []).map(p => ({
name: p.Name, exepath: p.ExePath || '',
timeout: p.GracefulCloseTimeoutSec ?? null }))
form.PreEnrollment = !!entry.PreEnrollment
form.KillAfterDetection = !!entry.KillAfterDetection
form.PCTypesStrict = !!entry.PCTypesStrict
entryForm.value = form
showEntry.value = true
}
function addProcess() {
if (!Array.isArray(entryForm.value.inuseProcesses)) entryForm.value.inuseProcesses = []
entryForm.value.inuseProcesses.push({ name: '', exepath: '', timeout: null })
}
function removeProcess(index) {
entryForm.value.inuseProcesses.splice(index, 1)
}
function splitList(value) {
return (value || '').split(',').map(s => s.trim()).filter(Boolean)
}
@@ -384,7 +412,7 @@ function buildEntryPayload() {
const scalars = ['Installer', 'InstallArgs', 'Script', 'Args', 'Source',
'Destination', 'RegPath', 'RegName', 'RegType', 'DetectionPath',
'DetectionName', 'DetectionValue', 'DetectionPattern', '_CmmVersion',
'ApplyMode', 'UpdateWindow', '_comment', 'payloadsource', 'payloadref']
'LogFile', 'ApplyMode', 'UpdateWindow', '_comment']
for (const key of scalars) {
if (f[key] !== undefined && f[key] !== '' && f[key] !== null) out[key] = f[key]
}
@@ -399,9 +427,21 @@ function buildEntryPayload() {
if (hostnames.length) out.TargetHostnames = hostnames
const machinenumbers = splitList(f.TargetMachineNumbers)
if (machinenumbers.length) out.TargetMachineNumbers = machinenumbers
for (const flag of ['PreEnrollment', 'KillAfterDetection', 'PCTypesStrict']) {
if (f[flag]) out[flag] = true
}
if (f.inuseBehavior) {
out.InUseCheck = { Behavior: f.inuseBehavior,
Processes: splitList(f.inuseProcesses).map(name => ({ Name: name })) }
out.InUseCheck = {
Behavior: f.inuseBehavior,
Processes: (f.inuseProcesses || []).filter(p => p.name).map(p => {
const pd = { Name: p.name }
if (p.exepath) pd.ExePath = p.exepath
if (p.timeout !== null && p.timeout !== '' && p.timeout !== undefined) {
pd.GracefulCloseTimeoutSec = Number(p.timeout)
}
return pd
}),
}
}
return out
}
@@ -420,9 +460,11 @@ async function saveEntry() {
}
async function deleteEntry(entry) {
if (!confirm(`Delete entry "${entry.Name}"?`)) return
try {
await api.delete(`/geenforce/entries/${entry.entryid}`)
await selectScope(detail.value.scopeid)
await loadScopes()
} catch (e) { error.value = 'Delete entry failed' }
}
async function moveEntry(index, direction) {
const entries = detail.value.entries
@@ -430,8 +472,10 @@ async function moveEntry(index, direction) {
if (target < 0 || target >= entries.length) return
const order = entries.map(e => e.entryid)
;[order[index], order[target]] = [order[target], order[index]]
try {
await api.put(`/geenforce/scopes/${detail.value.scopeid}/entries/reorder`, { order })
await selectScope(detail.value.scopeid)
} catch (e) { error.value = 'Reorder failed' }
}
// -- publish / versions --
@@ -449,25 +493,33 @@ async function toggleVersions() {
if (showVersions.value) await loadVersions()
}
async function loadVersions() {
try {
versions.value = payload(await api.get(`/geenforce/scopes/${detail.value.scopeid}/versions`))
} catch (e) { error.value = 'Failed to load versions' }
}
async function rollback(versionnumber) {
if (!confirm(`Roll back to v${versionnumber}? PCs get this on their next cycle.`)) return
try {
await api.post(`/geenforce/scopes/${detail.value.scopeid}/rollback`, { versionnumber })
await loadVersions()
await loadScopes()
} catch (e) { error.value = 'Rollback failed' }
}
// -- simulate / preview --
async function runSimulate() {
try {
const params = { ...sim.value }
simResult.value = payload(await api.get(
`/geenforce/scopes/${detail.value.scopeid}/simulate`, { params }))
} catch (e) { error.value = 'Simulate failed' }
}
async function loadPreview() {
try {
const data = payload(await api.get(`/geenforce/scopes/${detail.value.scopeid}/preview`))
previewText.value = JSON.stringify(data.manifest, null, 2)
showPreview.value = true
} catch (e) { error.value = 'Preview failed' }
}
function filterSummary(entry) {
@@ -544,6 +596,14 @@ loadConfig()
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0 1rem; }
.form-grid .full { grid-column: 1 / -1; }
.advanced summary { cursor: pointer; font-size: 0.85rem; margin-bottom: 0.5rem; }
.proc-head { display: flex; justify-content: space-between; align-items: center;
font-size: 0.78rem; color: var(--text-light); margin: 0.25rem 0; }
.proc-row { display: grid; grid-template-columns: 1fr 1.4fr 0.6fr auto; gap: 0.3rem;
margin-bottom: 0.3rem; }
.preinstall-flags { margin-top: 0.5rem; }
.flags-label { display: block; font-size: 0.75rem; color: var(--text-light); margin-bottom: 0.25rem; }
.inline { flex-direction: row !important; align-items: center; gap: 0.3rem !important;
display: inline-flex !important; margin-right: 1rem; }
.modal-actions { display: flex; justify-content: flex-end; gap: 0.5rem; margin-top: 0.75rem; }
.preview { background: var(--bg); padding: 0.75rem; border-radius: 6px; max-height: 60vh;
overflow: auto; font-size: 0.78rem; }

View File

@@ -11,8 +11,9 @@ Two audiences:
from functools import wraps
from flask import Blueprint, request, current_app, Response
from flask import Blueprint, request, Response
from flask_jwt_extended import jwt_required
from sqlalchemy.exc import IntegrityError
from shopdb.api import (
db, success_response, error_response, ErrorCodes, require_permission,
@@ -40,15 +41,17 @@ REPORT_SCOPE = 'geenforce.report'
def _require_service_token(scope):
"""Decorator factory: require `scope` service token OR the env bootstrap key."""
"""Decorator factory: require a managed service token scoped for `scope`.
Tokens are the only client auth path (the client kit provisions a
geenforce.fetch/report token). No env-key fallback: it was never wired into
config and an unscoped shared key is a needless backdoor.
"""
def wrapper(f):
@wraps(f)
def decorated(*args, **kwargs):
if service_token_authorized(scope):
return f(*args, **kwargs)
expected = current_app.config.get('GEENFORCE_API_KEY')
if expected and request.headers.get('X-API-Key') == expected:
return f(*args, **kwargs)
return error_response(ErrorCodes.UNAUTHORIZED, 'Invalid API key',
http_code=401)
return decorated
@@ -278,7 +281,13 @@ def create_entry(scopeid):
nextorder = max([e.sortorder for e in scope.entries], default=-1) + 1
entry = build_entry(payload, nextorder)
scope.entries.append(entry)
try:
db.session.commit()
except IntegrityError:
db.session.rollback()
return error_response(ErrorCodes.VALIDATION_ERROR,
'an entry with that Name already exists in this scope',
http_code=400)
return success_response(_entry_payload(entry), http_code=201)
@@ -293,8 +302,20 @@ def update_entry(entryid):
invalid = _validate_entry(payload)
if invalid:
return error_response(ErrorCodes.VALIDATION_ERROR, invalid, http_code=400)
# Free the one-to-one InUseCheck (unique entryid) before populate re-inserts
# it, so the replacement does not collide with the old row mid-flush.
if entry.inusecheck is not None:
db.session.delete(entry.inusecheck)
entry.inusecheck = None
db.session.flush()
populate_entry(entry, payload)
try:
db.session.commit()
except IntegrityError:
db.session.rollback()
return error_response(ErrorCodes.VALIDATION_ERROR,
'an entry with that Name already exists in this scope',
http_code=400)
return success_response(_entry_payload(entry))

View File

@@ -43,13 +43,22 @@ def _alias_sets(name):
def matches_pctype(entry, pctype, subtype=None):
"""Test-PCTypeMatches: no PCTypes = all; '*' = all; alias-set intersection."""
"""Test-PCTypeMatches: no PCTypes = all; '*' = all; alias-set intersection.
PCTypesStrict=true disables alias expansion (both the PC's identity and the
manifest values are compared literally), so e.g. a 'collections'-only strict
entry does NOT match a 'nocollections' PC via their shared 'Standard' alias
group. The preinstall runner honors this flag (see preinstall.json UDC entry);
mirroring it keeps the "what would this PC get" simulator correct.
"""
values = entry.get('PCTypes') or []
if not values:
return True
if not pctype:
return True
# Names the current PC matches: bare type, "type-subtype", and all aliases.
strict = bool(entry.get('PCTypesStrict'))
# Names the current PC matches: bare type, "type-subtype", and (unless strict)
# all aliases of either.
mynames = set()
mynames.add(pctype.lower())
seeds = [pctype]
@@ -57,6 +66,7 @@ def matches_pctype(entry, pctype, subtype=None):
combined = f'{pctype}-{subtype}'
mynames.add(combined.lower())
seeds.append(combined)
if not strict:
for seed in seeds:
for group in _alias_sets(seed):
for alias in group:
@@ -66,6 +76,8 @@ def matches_pctype(entry, pctype, subtype=None):
return True
if value.lower() in mynames:
return True
if strict:
continue
# The manifest value may itself be an alias - expand and check overlap.
for group in _alias_sets(value):
for alias in group:

View File

@@ -34,10 +34,19 @@ def check_scope(scopename, phase, original, fixtures):
orig_apps = original.get('Applications') or []
rebuilt_apps = rebuilt.get('Applications') or []
# Check 0: scope-level fields (Version, Site) round-trip.
firstdiff = None
for key in ('Version', 'Site'):
if str(original.get(key) or '') != str(rebuilt.get(key) or ''):
firstdiff = (f'scope field {key}: '
f'{original.get(key)!r} vs {rebuilt.get(key)!r}')
break
scope_ok = firstdiff is None
# Check 1: field-identical, order-preserving.
identical = 0
firstdiff = None
if len(orig_apps) != len(rebuilt_apps):
if firstdiff is None and len(orig_apps) != len(rebuilt_apps):
firstdiff = (f'entry count {len(orig_apps)} vs {len(rebuilt_apps)}')
for i in range(min(len(orig_apps), len(rebuilt_apps))):
co = canonical_entry(orig_apps[i])
@@ -59,7 +68,8 @@ def check_scope(scopename, phase, original, fixtures):
elif firstdiff is None:
firstdiff = f'filter mismatch for profile {profile.get("label")}'
passed = (identical == len(orig_apps) == len(rebuilt_apps)
passed = (scope_ok
and identical == len(orig_apps) == len(rebuilt_apps)
and profiles_same == len(fixtures))
return {
'scopename': scopename,

View File

@@ -8,6 +8,7 @@ Kept out of the CLI and routes so both share one implementation:
"""
import os
import tempfile
from datetime import datetime, timezone
from sqlalchemy import func
@@ -122,10 +123,15 @@ def record_enforcement_report(payload):
else:
status = 'ok'
# Demote the prior current report for this host+scope.
ManifestEnforcementReport.query.filter_by(
hostname=hostname, scopename=scopename, phase=phase, iscurrent=True
).update({'iscurrent': False})
# Demote the prior current report for this host+scope. Hostname match is
# case-insensitive to match the ilike read path - otherwise a PC reporting
# its name in different casing would leave two iscurrent rows.
ManifestEnforcementReport.query.filter(
func.lower(ManifestEnforcementReport.hostname) == hostname.lower(),
ManifestEnforcementReport.scopename == scopename,
ManifestEnforcementReport.phase == phase,
ManifestEnforcementReport.iscurrent == True
).update({'iscurrent': False}, synchronize_session=False)
report = ManifestEnforcementReport(
hostname=hostname,
@@ -189,6 +195,17 @@ def export_scope_to_share(scopename, phase, shareroot):
with open(os.path.join(historydir, f'{stamp}-{scopename}.json'), 'w') as dst:
dst.write(old)
with open(target, 'w') as handle:
# Atomic write: a partial/failed write must never leave the live on-share
# manifest (which every PC reads) truncated. Write a temp file in the same
# directory, then rename over the target.
targetdir = os.path.dirname(target)
fd, tmppath = tempfile.mkstemp(dir=targetdir, suffix='.tmp')
try:
with os.fdopen(fd, 'w') as handle:
handle.write(published.manifestjson)
os.replace(tmppath, target)
except Exception:
if os.path.exists(tmppath):
os.remove(tmppath)
raise
return target

View File

@@ -80,6 +80,34 @@ def test_entry_crud_and_reorder(client, db, auth_headers):
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',
@@ -117,6 +145,34 @@ def test_simulate_cmm_version_gate(client, db, auth_headers):
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 (mirrors the preinstall UDC entry)."""
scopeid = _create_scope(client, auth_headers, 'preinstall')
_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,

View File

@@ -131,6 +131,55 @@ def test_reference_share_round_trips_losslessly():
assert ok, f"parity failures: {[(r['scopename'], r['firstdiff']) for r in failed]}"
@pytest.mark.skipif(not os.path.isdir(REFERENCE_SHARE),
reason='GE-Enforce reference share not present')
def test_reference_manifests_fit_column_limits():
"""Guard the truncation blind spot: the DB-free parity harness can't catch a
value that exceeds its column length (Python strings are unbounded), so a
260-char DetectionPath would pass parity yet truncate on a real insert.
Assert every real-manifest string field fits its declared column, deriving
limits from the model so this never drifts.
"""
from plugins.geenforce.models import (
ManifestEntry, ManifestEntryPcType, ManifestEntryHostname,
ManifestEntryMachineNumber)
def limit(model, attr):
return model.__table__.columns[attr].type.length
# manifest key -> (model, column attribute)
entry_fields = {
'Name': 'name', 'Installer': 'installer', 'Script': 'scriptpath',
'Args': 'scriptargs', 'Source': 'sourcepath', 'Destination': 'destination',
'RegPath': 'regpath', 'RegName': 'regname', 'RegType': 'regtype',
'DetectionPath': 'detectionpath', 'DetectionName': 'detectionname',
'DetectionValue': 'detectionvalue', 'DetectionPattern': 'detectionpattern',
'_CmmVersion': 'cmmversion', 'LogFile': 'logfile',
'UpdateWindow': 'updatewindow', 'ApplyMode': 'applymode',
}
overflows = []
manifests = list(discover_share(REFERENCE_SHARE))
if os.path.isfile(REFERENCE_PREINSTALL):
manifests.append(('preinstall', 'preinstall',
load_manifest_file(REFERENCE_PREINSTALL)))
for scopename, _phase, manifest in manifests:
for entry in manifest.get('Applications', []):
for key, attr in entry_fields.items():
value = entry.get(key)
if isinstance(value, str) and len(value) > limit(ManifestEntry, attr):
overflows.append(f'{scopename}/{entry.get("Name")}.{key}')
for value in entry.get('PCTypes', []):
if len(value) > limit(ManifestEntryPcType, 'pctypevalue'):
overflows.append(f'{scopename}/{entry.get("Name")}.PCTypes')
for value in entry.get('TargetHostnames', []):
if len(value) > limit(ManifestEntryHostname, 'hostnamepattern'):
overflows.append(f'{scopename}/{entry.get("Name")}.TargetHostnames')
for value in entry.get('TargetMachineNumbers', []):
if len(str(value)) > limit(ManifestEntryMachineNumber, 'machinenumber'):
overflows.append(f'{scopename}/{entry.get("Name")}.TargetMachineNumbers')
assert not overflows, f'fields exceed column limits (would truncate): {overflows}'
def test_fixtures_cover_every_pctype():
"""The machine-profile fixtures include one of each imaging pctype."""
labels = {f['pctype'] for f in load_fixtures()}

View File

@@ -97,6 +97,24 @@ def test_latest_report_upserts_per_host(client, db, app, auth_headers):
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'])