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:
166
frontend/src/views/geenforce/entryForm.js
Normal file
166
frontend/src/views/geenforce/entryForm.js
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user