Fix defects found in session review of GE-Enforce plugin
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:
@@ -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; }
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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'])
|
||||
|
||||
Reference in New Issue
Block a user