GE-Enforce: compliance view, inline payload upload, frontend test harness

Fleet-install compliance for app-linked manifest entries: new service
compliance_for_scope + GET /geenforce/scopes/<id>/compliance count active
ComputerInstalledApp rows by curated appid (null-safe when computers plugin
absent). ManifestEditor gains a compliance panel. Curated appid stays shopdb
metadata and never enters manifest JSON, so behavioral parity is unaffected.

Inline manifest payloads: store_inline_payload (sha256, 1MB cap,
payloadsource='inline') + POST/GET /geenforce/entries/<id>/payload; editor
gains an upload control. Entry payload metadata surfaced in _entry_payload.

Frontend test harness: extract the editor's entry-form logic into pure
entryForm.js (buildEntryPayload, describeEntry, availableEntryTypes, scope
gates, ...) and cover it with 45 vitest tests. ManifestEditor now imports
those helpers, so the tests exercise the shipped code path (no duplication).

908 backend tests pass; vitest 45 pass; frontend build green; naming green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
cproudlock
2026-07-13 07:24:51 -04:00
parent 3355436fcd
commit 4e5b4228c1
10 changed files with 3411 additions and 145 deletions

View File

@@ -0,0 +1,166 @@
// Pure, framework-free helpers for the GE-Enforce manifest editor.
//
// These functions are lifted verbatim (behavior-for-behavior) from
// ManifestEditor.vue so they can be unit tested without mounting the whole
// component. ManifestEditor.vue still hosts its own copies today; a follow-up
// should point the component at this module (import from here) so the tested
// code and the shipped code are the same source. Until then keep the two in
// sync: any change to the editor logic must land here too.
//
// Everything here is a plain function of its inputs. No Vue, no reactivity,
// no network. That is the whole point - deterministic logic we can pin down.
export const ENTRY_TYPES = ['MSI', 'EXE', 'CMD', 'BAT', 'PS1', 'INF', 'File', 'Registry']
export const REG_TYPES = ['String', 'DWord', 'QWord', 'MultiString', 'ExpandString', 'Binary']
export const DETECTION_METHODS = ['Registry', 'File', 'FileVersion', 'Hash', 'MarkerFile',
'ValueMatches', 'pnputil', 'Always']
export const INUSE_BEHAVIORS = ['Defer', 'CloseAndReopen', 'ForceClose', 'ScheduleForReboot']
// One blank entry form, matching the shape ManifestEditor seeds for a new entry.
export function blankEntry() {
return { Type: 'MSI', DetectionMethod: '', RegType: 'String',
inuseBehavior: '', inuseProcesses: [], appid: null,
PreEnrollment: false, KillAfterDetection: false, PCTypesStrict: false }
}
// Split a comma list into trimmed non-empty parts.
export function splitList(value) {
return (value || '').split(',').map(item => item.trim()).filter(Boolean)
}
// Turn an edit-form object into the API body sent to create/update an entry.
//
// Rules that matter (and that the specs pin):
// - appid is always present (null unlinks). It is shopdb metadata; the backend
// keeps it OFF the manifest JSON, so it is NOT one of the manifest scalars.
// - empty/undefined/null scalars are dropped.
// - Registry DWord/QWord values become real numbers; other reg types stay strings.
// - comma lists become arrays, and are dropped when empty.
// - preinstall flags only appear when truthy.
// - InUseCheck carries per-process Name, optional ExePath, optional numeric timeout.
export function buildEntryPayload(form) {
// appid is shopdb metadata, always sent (null unlinks); the backend keeps it
// off the manifest JSON.
const out = { Name: form.Name, Type: form.Type, appid: form.appid ?? null }
const scalars = ['Installer', 'InstallArgs', 'Script', 'Args', 'Source',
'Destination', 'RegPath', 'RegName', 'RegType', 'DetectionPath',
'DetectionName', 'DetectionValue', 'DetectionPattern', '_CmmVersion',
'LogFile', 'ApplyMode', 'UpdateWindow', '_comment']
for (const key of scalars) {
if (form[key] !== undefined && form[key] !== '' && form[key] !== null) out[key] = form[key]
}
if (form.DetectionMethod) out.DetectionMethod = form.DetectionMethod
if (form.WaitTimeoutSec) out.WaitTimeoutSec = form.WaitTimeoutSec
if (form.Type === 'Registry' && form.RegValue !== undefined && form.RegValue !== '') {
out.RegValue = ['DWord', 'QWord'].includes(form.RegType) ? Number(form.RegValue) : form.RegValue
}
const pctypes = splitList(form.PCTypes)
if (pctypes.length) out.PCTypes = pctypes
const hostnames = splitList(form.TargetHostnames)
if (hostnames.length) out.TargetHostnames = hostnames
const machinenumbers = splitList(form.TargetMachineNumbers)
if (machinenumbers.length) out.TargetMachineNumbers = machinenumbers
for (const flag of ['PreEnrollment', 'KillAfterDetection', 'PCTypesStrict']) {
if (form[flag]) out[flag] = true
}
if (form.inuseBehavior) {
out.InUseCheck = {
Behavior: form.inuseBehavior,
Processes: (form.inuseProcesses || []).filter(process => process.name).map(process => {
const processData = { Name: process.name }
if (process.exepath) processData.ExePath = process.exepath
if (process.timeout !== null && process.timeout !== '' && process.timeout !== undefined) {
processData.GracefulCloseTimeoutSec = Number(process.timeout)
}
return processData
}),
}
}
return out
}
// Which entry types the type dropdown offers, given the scope phase. Preinstall
// supports MSI/EXE only, but keeps a current out-of-set value visible.
export function availableEntryTypes(isPreinstall, currentType) {
if (!isPreinstall) return ENTRY_TYPES
const allowed = ['MSI', 'EXE']
return currentType && !allowed.includes(currentType) ? [...allowed, currentType] : allowed
}
// Which detection methods the dropdown offers. Preinstall supports Registry/File
// only, but keeps a current out-of-set value visible.
export function availableDetectionMethods(isPreinstall, currentMethod) {
if (!isPreinstall) return DETECTION_METHODS
const allowed = ['Registry', 'File']
return currentMethod && !allowed.includes(currentMethod) ? [...allowed, currentMethod] : allowed
}
// Which targeting gates a scope actually surfaces by default (before "Show all").
export function targetingGates(scope) {
if (!scope) {
return { pctypes: true, cmmversion: false, machinenumbers: false, hostnames: false }
}
const name = (scope.scopename || '').toLowerCase()
const entries = scope.entries || []
const fleetwide = scope.iscommon || scope.phase === 'preinstall' || name === 'common'
return {
pctypes: fleetwide || entries.some(e => (e.PCTypes || []).length),
cmmversion: /cmm/.test(name) || entries.some(e => e._CmmVersion),
// Data-driven (a loose name match would wrongly flag 'nocollections').
machinenumbers: entries.some(e => (e.TargetMachineNumbers || []).length),
hostnames: entries.some(e => (e.TargetHostnames || []).length),
}
}
// The contextual hint under the Targeting header.
export function targetingHint(scope) {
if (!scope) return ''
if (scope.iscommon || scope.scopename === 'common') {
return 'Fleet-wide manifest: use PC types to target which types get this entry.'
}
if (scope.phase === 'preinstall') {
return 'Preinstall manifest: use PC types to target; preinstall flags apply here.'
}
return `This manifest already runs only on ${scope.scopename} PCs. `
+ 'Targeting below narrows within that (version, bay, or subtype).'
}
// One-line summary of what a scope installs (installer entries only).
export function scopeSummary(scope) {
if (!scope || !scope.entries || !scope.entries.length) return ''
const installers = scope.entries
.filter(e => ['MSI', 'EXE', 'CMD', 'BAT'].includes(e.Type))
.map(e => e.Name)
const shown = installers.slice(0, 6).join(', ')
const more = installers.length > 6 ? `, +${installers.length - 6} more` : ''
const apps = installers.length ? `Installs ${shown}${more}. ` : ''
return `${apps}${scope.entries.length} entries; runs after common.`
}
// Verb table for describeEntry.
export const ACTION_VERB = {
MSI: 'Installs', EXE: 'Installs', CMD: 'Runs', BAT: 'Runs',
PS1: 'Runs a script for', INF: 'Installs a driver for',
File: 'Copies a file for', Registry: 'Sets a registry value for',
}
// Plain-English one-liner: what an entry does, when it self-heals, who it hits.
export function describeEntry(entry) {
let text = `${ACTION_VERB[entry.Type] || 'Applies'} ${entry.Name}`
const method = entry.DetectionMethod
if (!method || method === 'Always') text += '; runs every cycle'
else if (method === 'FileVersion') text += `; reinstalls unless version is ${entry.DetectionValue || 'set'}`
else if (method === 'Hash') text += '; re-copies if the file changed'
else if (method === 'MarkerFile') text += '; installs once'
else text += '; reinstalls if not detected'
if (entry._CmmVersion) text += `; CMM ${entry._CmmVersion} bays only`
else if (entry.TargetMachineNumbers && entry.TargetMachineNumbers.length) {
text += `; ${entry.TargetMachineNumbers.length} specific bay(s)`
} else if (entry.TargetHostnames && entry.TargetHostnames.length) {
text += '; specific hostname(s)'
} else if (entry.PCTypes && entry.PCTypes.length) {
text += `; ${entry.PCTypes.length} PC type(s)`
}
if (entry.appname) text += `; tracked: ${entry.appname}`
return text
}