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:
2292
frontend/package-lock.json
generated
2292
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,9 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource-variable/inter": "^5.2.8",
|
"@fontsource-variable/inter": "^5.2.8",
|
||||||
@@ -25,6 +27,9 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-vue": "^5.2.4",
|
"@vitejs/plugin-vue": "^5.2.4",
|
||||||
"vite": "^6.4.1"
|
"@vue/test-utils": "^2.4.6",
|
||||||
|
"jsdom": "^25.0.1",
|
||||||
|
"vite": "^6.4.1",
|
||||||
|
"vitest": "^2.1.9"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -218,6 +218,48 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Compliance: install coverage for tracked-application entries -->
|
||||||
|
<div class="section-card">
|
||||||
|
<h3 class="section-title">Compliance: who has these installed?</h3>
|
||||||
|
<p class="setting-description">
|
||||||
|
Install counts for entries linked to a tracked application, from the
|
||||||
|
computers collector. Only entries with a tracked app appear.
|
||||||
|
</p>
|
||||||
|
<div v-if="compliance && compliance.computersplugin === false" class="muted compliance-note">
|
||||||
|
Install counts require the computers plugin.
|
||||||
|
</div>
|
||||||
|
<div class="table-container">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Entry</th>
|
||||||
|
<th>Tracked app</th>
|
||||||
|
<th>Expected version</th>
|
||||||
|
<th>Installed</th>
|
||||||
|
<th>On expected version</th>
|
||||||
|
<th>Note</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="row in complianceRows" :key="row.entryid">
|
||||||
|
<td>{{ row.entryname }}</td>
|
||||||
|
<td>{{ row.appname }}</td>
|
||||||
|
<td class="muted">{{ row.expectedversion || '-' }}</td>
|
||||||
|
<td>{{ row.installedcount === null ? '-' : row.installedcount }}</td>
|
||||||
|
<td>
|
||||||
|
<span v-if="row.versionmatchcount === null" class="muted">-</span>
|
||||||
|
<span v-else>{{ row.versionmatchcount }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="muted">{{ row.coveragenote }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="!complianceRows.length">
|
||||||
|
<td colspan="6" class="empty">No entries are linked to a tracked application yet.</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="section-card empty-detail">
|
<div v-else class="section-card empty-detail">
|
||||||
@@ -334,6 +376,30 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- inline payload upload (only for a saved entry) -->
|
||||||
|
<div v-if="entryForm.entryid" class="setting-group">
|
||||||
|
<h3>Inline payload</h3>
|
||||||
|
<p class="setting-description">
|
||||||
|
Attach a small file (installer config, .reg, script) stored in
|
||||||
|
shopdb and shipped with this entry.
|
||||||
|
</p>
|
||||||
|
<div v-if="entryForm.haspayload" class="payload-current muted">
|
||||||
|
Current: {{ entryForm.payloadref }}
|
||||||
|
<span v-if="entryForm.payloadsha256">(sha256 {{ entryForm.payloadsha256.slice(0, 12) }})</span>
|
||||||
|
</div>
|
||||||
|
<div v-else class="payload-current muted">No payload attached.</div>
|
||||||
|
<div class="payload-upload">
|
||||||
|
<input type="file" @change="onPayloadFileChange" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-sm btn-secondary"
|
||||||
|
:disabled="!payloadFile || payloadUploading"
|
||||||
|
@click="uploadPayload"
|
||||||
|
>{{ payloadUploading ? 'Uploading...' : 'Upload' }}</button>
|
||||||
|
</div>
|
||||||
|
<small class="input-hint">Inline payloads are capped at 1 MB and stored in shopdb.</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- detection -->
|
<!-- detection -->
|
||||||
<div class="setting-row">
|
<div class="setting-row">
|
||||||
<label>
|
<label>
|
||||||
@@ -353,7 +419,11 @@
|
|||||||
<label><span>Detection name</span><input v-model="entryForm.DetectionName" /></label>
|
<label><span>Detection name</span><input v-model="entryForm.DetectionName" /></label>
|
||||||
</div>
|
</div>
|
||||||
<div class="setting-row">
|
<div class="setting-row">
|
||||||
<label><span>Detection value</span><input v-model="entryForm.DetectionValue" /></label>
|
<label>
|
||||||
|
<span>Detection value</span>
|
||||||
|
<input v-model="entryForm.DetectionValue" />
|
||||||
|
<small class="input-hint">For FileVersion detection this is the expected version used by the Compliance panel.</small>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="entryForm.DetectionMethod === 'pnputil'" class="setting-row">
|
<div v-if="entryForm.DetectionMethod === 'pnputil'" class="setting-row">
|
||||||
<label><span>Detection pattern</span><input v-model="entryForm.DetectionPattern" /></label>
|
<label><span>Detection pattern</span><input v-model="entryForm.DetectionPattern" /></label>
|
||||||
@@ -491,12 +561,16 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import api from '../../api'
|
import api from '../../api'
|
||||||
|
// Pure entry-form logic lives in entryForm.js (unit-tested by entryForm.spec.js);
|
||||||
const ENTRY_TYPES = ['MSI', 'EXE', 'CMD', 'BAT', 'PS1', 'INF', 'File', 'Registry']
|
// this component wires it to reactive state so the tests cover the shipped code.
|
||||||
const REG_TYPES = ['String', 'DWord', 'QWord', 'MultiString', 'ExpandString', 'Binary']
|
import {
|
||||||
const DETECTION_METHODS = ['Registry', 'File', 'FileVersion', 'Hash', 'MarkerFile',
|
ENTRY_TYPES, REG_TYPES, DETECTION_METHODS, INUSE_BEHAVIORS,
|
||||||
'ValueMatches', 'pnputil', 'Always']
|
blankEntry, buildEntryPayload as buildEntryPayloadPure,
|
||||||
const INUSE_BEHAVIORS = ['Defer', 'CloseAndReopen', 'ForceClose', 'ScheduleForReboot']
|
describeEntry, availableEntryTypes as availableEntryTypesFor,
|
||||||
|
availableDetectionMethods as availableDetectionMethodsFor,
|
||||||
|
targetingGates as targetingGatesFor, targetingHint as targetingHintFor,
|
||||||
|
scopeSummary as scopeSummaryFor,
|
||||||
|
} from './entryForm'
|
||||||
|
|
||||||
const scopes = ref([])
|
const scopes = ref([])
|
||||||
const selectedId = ref(null)
|
const selectedId = ref(null)
|
||||||
@@ -510,66 +584,14 @@ const shareRoot = ref('')
|
|||||||
// and only preinstall honors the preinstall flags. Restrict the form for a
|
// and only preinstall honors the preinstall flags. Restrict the form for a
|
||||||
// preinstall scope so an author cannot pick an option that would do nothing.
|
// preinstall scope so an author cannot pick an option that would do nothing.
|
||||||
const isPreinstallScope = computed(() => detail.value?.phase === 'preinstall')
|
const isPreinstallScope = computed(() => detail.value?.phase === 'preinstall')
|
||||||
const availableEntryTypes = computed(() => {
|
const availableEntryTypes = computed(() =>
|
||||||
if (!isPreinstallScope.value) return ENTRY_TYPES
|
availableEntryTypesFor(isPreinstallScope.value, entryForm.value?.Type))
|
||||||
const allowed = ['MSI', 'EXE']
|
const availableDetectionMethods = computed(() =>
|
||||||
// keep the current value visible even if it is out of the allowed set
|
availableDetectionMethodsFor(isPreinstallScope.value, entryForm.value?.DetectionMethod))
|
||||||
const current = entryForm.value?.Type
|
const scopeSummary = computed(() => scopeSummaryFor(detail.value))
|
||||||
return current && !allowed.includes(current) ? [...allowed, current] : allowed
|
|
||||||
})
|
|
||||||
const availableDetectionMethods = computed(() => {
|
|
||||||
if (!isPreinstallScope.value) return DETECTION_METHODS
|
|
||||||
const allowed = ['Registry', 'File']
|
|
||||||
const current = entryForm.value?.DetectionMethod
|
|
||||||
return current && !allowed.includes(current) ? [...allowed, current] : allowed
|
|
||||||
})
|
|
||||||
|
|
||||||
// One-line summary of what a scope installs (installer entries only).
|
|
||||||
const scopeSummary = computed(() => {
|
|
||||||
const scope = detail.value
|
|
||||||
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.`
|
|
||||||
})
|
|
||||||
|
|
||||||
// Show only the targeting gates a scope actually uses (a per-type manifest
|
|
||||||
// already runs on its own type, so PCTypes is noise there; CMM shows a version
|
|
||||||
// gate; collections shows machine numbers; the common/preinstall manifests use
|
|
||||||
// PCTypes to target subsets). "Show all" reveals every gate.
|
|
||||||
const showAllTargeting = ref(false)
|
const showAllTargeting = ref(false)
|
||||||
const targetingGates = computed(() => {
|
const targetingGates = computed(() => targetingGatesFor(detail.value))
|
||||||
const scope = detail.value
|
const targetingHint = computed(() => targetingHintFor(detail.value))
|
||||||
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),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
const targetingHint = computed(() => {
|
|
||||||
const scope = detail.value
|
|
||||||
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).'
|
|
||||||
})
|
|
||||||
|
|
||||||
const showNewScope = ref(false)
|
const showNewScope = ref(false)
|
||||||
const newScope = ref({ scopename: '', phase: 'runtime' })
|
const newScope = ref({ scopename: '', phase: 'runtime' })
|
||||||
@@ -587,6 +609,12 @@ const previewText = ref('')
|
|||||||
const sim = ref({ subtype: '', hostname: '', machinenumber: '', cmmversion: '' })
|
const sim = ref({ subtype: '', hostname: '', machinenumber: '', cmmversion: '' })
|
||||||
const simResult = ref(null)
|
const simResult = ref(null)
|
||||||
|
|
||||||
|
const compliance = ref(null)
|
||||||
|
const complianceRows = computed(() => compliance.value?.rows || [])
|
||||||
|
|
||||||
|
const payloadFile = ref(null)
|
||||||
|
const payloadUploading = ref(false)
|
||||||
|
|
||||||
function payload(response) { return response.data.data }
|
function payload(response) { return response.data.data }
|
||||||
|
|
||||||
function flash(message) { notice.value = message; setTimeout(() => { notice.value = '' }, 4000) }
|
function flash(message) { notice.value = message; setTimeout(() => { notice.value = '' }, 4000) }
|
||||||
@@ -616,10 +644,17 @@ async function selectScope(id) {
|
|||||||
selectedId.value = id
|
selectedId.value = id
|
||||||
showVersions.value = false
|
showVersions.value = false
|
||||||
simResult.value = null
|
simResult.value = null
|
||||||
|
compliance.value = null
|
||||||
try {
|
try {
|
||||||
detail.value = payload(await api.get(`/geenforce/scopes/${id}`))
|
detail.value = payload(await api.get(`/geenforce/scopes/${id}`))
|
||||||
|
loadCompliance(id)
|
||||||
} catch (e) { error.value = 'Failed to load PC type' }
|
} catch (e) { error.value = 'Failed to load PC type' }
|
||||||
}
|
}
|
||||||
|
async function loadCompliance(id) {
|
||||||
|
try {
|
||||||
|
compliance.value = payload(await api.get(`/geenforce/scopes/${id}/compliance`))
|
||||||
|
} catch (e) { compliance.value = null }
|
||||||
|
}
|
||||||
|
|
||||||
function openNewScope() {
|
function openNewScope() {
|
||||||
newScope.value = { scopename: '', phase: 'runtime' }
|
newScope.value = { scopename: '', phase: 'runtime' }
|
||||||
@@ -654,13 +689,9 @@ async function deleteScope() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// -- entries --
|
// -- entries --
|
||||||
function blankEntry() {
|
|
||||||
return { Type: 'MSI', DetectionMethod: '', RegType: 'String',
|
|
||||||
inuseBehavior: '', inuseProcesses: [], appid: null,
|
|
||||||
PreEnrollment: false, KillAfterDetection: false, PCTypesStrict: false }
|
|
||||||
}
|
|
||||||
function openNewEntry() {
|
function openNewEntry() {
|
||||||
entryForm.value = blankEntry()
|
entryForm.value = blankEntry()
|
||||||
|
payloadFile.value = null
|
||||||
showEntry.value = true
|
showEntry.value = true
|
||||||
}
|
}
|
||||||
function openEditEntry(entry) {
|
function openEditEntry(entry) {
|
||||||
@@ -679,6 +710,7 @@ function openEditEntry(entry) {
|
|||||||
form.KillAfterDetection = !!entry.KillAfterDetection
|
form.KillAfterDetection = !!entry.KillAfterDetection
|
||||||
form.PCTypesStrict = !!entry.PCTypesStrict
|
form.PCTypesStrict = !!entry.PCTypesStrict
|
||||||
entryForm.value = form
|
entryForm.value = form
|
||||||
|
payloadFile.value = null
|
||||||
showEntry.value = true
|
showEntry.value = true
|
||||||
}
|
}
|
||||||
function addProcess() {
|
function addProcess() {
|
||||||
@@ -688,49 +720,8 @@ function addProcess() {
|
|||||||
function removeProcess(index) {
|
function removeProcess(index) {
|
||||||
entryForm.value.inuseProcesses.splice(index, 1)
|
entryForm.value.inuseProcesses.splice(index, 1)
|
||||||
}
|
}
|
||||||
function splitList(value) {
|
|
||||||
return (value || '').split(',').map(item => item.trim()).filter(Boolean)
|
|
||||||
}
|
|
||||||
function buildEntryPayload() {
|
function buildEntryPayload() {
|
||||||
const form = entryForm.value
|
return buildEntryPayloadPure(entryForm.value)
|
||||||
// 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
|
|
||||||
}
|
}
|
||||||
async function saveEntry() {
|
async function saveEntry() {
|
||||||
const body = buildEntryPayload()
|
const body = buildEntryPayload()
|
||||||
@@ -745,6 +736,28 @@ async function saveEntry() {
|
|||||||
await loadScopes()
|
await loadScopes()
|
||||||
} catch (e) { error.value = e.response?.data?.message || 'Save entry failed' }
|
} catch (e) { error.value = e.response?.data?.message || 'Save entry failed' }
|
||||||
}
|
}
|
||||||
|
function onPayloadFileChange(event) {
|
||||||
|
payloadFile.value = event.target.files?.[0] || null
|
||||||
|
}
|
||||||
|
async function uploadPayload() {
|
||||||
|
if (!entryForm.value.entryid || !payloadFile.value) return
|
||||||
|
payloadUploading.value = true
|
||||||
|
try {
|
||||||
|
const form = new FormData()
|
||||||
|
form.append('file', payloadFile.value)
|
||||||
|
const updated = payload(await api.post(
|
||||||
|
`/geenforce/entries/${entryForm.value.entryid}/payload`, form,
|
||||||
|
{ headers: { 'Content-Type': 'multipart/form-data' } }))
|
||||||
|
entryForm.value.payloadsource = updated.payloadsource
|
||||||
|
entryForm.value.payloadref = updated.payloadref
|
||||||
|
entryForm.value.payloadsha256 = updated.payloadsha256
|
||||||
|
entryForm.value.haspayload = updated.haspayload
|
||||||
|
payloadFile.value = null
|
||||||
|
flash('Payload uploaded.')
|
||||||
|
await selectScope(detail.value.scopeid)
|
||||||
|
} catch (e) { error.value = e.response?.data?.message || 'Payload upload failed' }
|
||||||
|
finally { payloadUploading.value = false }
|
||||||
|
}
|
||||||
async function deleteEntry(entry) {
|
async function deleteEntry(entry) {
|
||||||
if (!confirm(`Delete entry "${entry.Name}"?`)) return
|
if (!confirm(`Delete entry "${entry.Name}"?`)) return
|
||||||
try {
|
try {
|
||||||
@@ -820,30 +833,6 @@ function filterSummary(entry) {
|
|||||||
|
|
||||||
// Plain-English one-liner: what this entry does, when it self-heals, who it
|
// Plain-English one-liner: what this entry does, when it self-heals, who it
|
||||||
// applies to. Turns "eDNC | MSI | Registry | -" into intent an IT tech reads.
|
// applies to. Turns "eDNC | MSI | Registry | -" into intent an IT tech reads.
|
||||||
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',
|
|
||||||
}
|
|
||||||
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
|
|
||||||
}
|
|
||||||
function formatDate(value) {
|
function formatDate(value) {
|
||||||
return value ? new Date(value).toLocaleString() : ''
|
return value ? new Date(value).toLocaleString() : ''
|
||||||
}
|
}
|
||||||
@@ -1033,4 +1022,18 @@ loadApplications()
|
|||||||
|
|
||||||
/* Reorder / disabled buttons */
|
/* Reorder / disabled buttons */
|
||||||
.btn:disabled { opacity: 0.4; cursor: default; }
|
.btn:disabled { opacity: 0.4; cursor: default; }
|
||||||
|
|
||||||
|
/* Inline payload upload */
|
||||||
|
.payload-current { font-size: 0.85rem; margin-bottom: 0.5rem; }
|
||||||
|
.payload-upload {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.payload-upload input[type="file"] { min-width: 0; font-size: 0.85rem; }
|
||||||
|
|
||||||
|
/* Compliance panel */
|
||||||
|
.compliance-note { margin-bottom: 0.75rem; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
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
|
||||||
|
}
|
||||||
302
frontend/src/views/geenforce/entryForm.spec.js
Normal file
302
frontend/src/views/geenforce/entryForm.spec.js
Normal file
@@ -0,0 +1,302 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import {
|
||||||
|
blankEntry,
|
||||||
|
splitList,
|
||||||
|
buildEntryPayload,
|
||||||
|
availableEntryTypes,
|
||||||
|
availableDetectionMethods,
|
||||||
|
targetingGates,
|
||||||
|
targetingHint,
|
||||||
|
scopeSummary,
|
||||||
|
describeEntry,
|
||||||
|
ENTRY_TYPES,
|
||||||
|
DETECTION_METHODS,
|
||||||
|
} from './entryForm.js'
|
||||||
|
|
||||||
|
// These specs pin the deterministic logic that turns the manifest editor form
|
||||||
|
// into an API body, and the various display helpers. This is the code most
|
||||||
|
// likely to silently regress (numeric coercion, dropped-vs-kept fields, the
|
||||||
|
// appid-must-never-be-a-manifest-scalar rule).
|
||||||
|
|
||||||
|
describe('splitList', () => {
|
||||||
|
it('trims, drops empties, and returns an array', () => {
|
||||||
|
expect(splitList('a, b ,, c ')).toEqual(['a', 'b', 'c'])
|
||||||
|
})
|
||||||
|
it('returns an empty array for null/empty', () => {
|
||||||
|
expect(splitList('')).toEqual([])
|
||||||
|
expect(splitList(null)).toEqual([])
|
||||||
|
expect(splitList(undefined)).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('buildEntryPayload - appid handling', () => {
|
||||||
|
it('always emits appid as a top-level key, defaulting to null', () => {
|
||||||
|
const out = buildEntryPayload({ Name: 'x', Type: 'MSI' })
|
||||||
|
expect(out.appid).toBeNull()
|
||||||
|
expect('appid' in out).toBe(true)
|
||||||
|
})
|
||||||
|
it('passes a linked appid through unchanged', () => {
|
||||||
|
const out = buildEntryPayload({ Name: 'x', Type: 'MSI', appid: 41 })
|
||||||
|
expect(out.appid).toBe(41)
|
||||||
|
})
|
||||||
|
it('never lets appid become a manifest scalar (it is metadata, not a manifest key)', () => {
|
||||||
|
// appid lives at the top level as metadata; it must not appear nested in
|
||||||
|
// any manifest sub-structure. Guard against a future refactor smuggling it
|
||||||
|
// into e.g. InUseCheck or a detection block.
|
||||||
|
const out = buildEntryPayload({
|
||||||
|
Name: 'x', Type: 'Registry', appid: 7, RegType: 'DWord', RegValue: '1',
|
||||||
|
DetectionMethod: 'Registry', inuseBehavior: 'ForceClose',
|
||||||
|
inuseProcesses: [{ name: 'p', exepath: '', timeout: null }],
|
||||||
|
})
|
||||||
|
// Only the top-level appid key carries it.
|
||||||
|
const serialized = JSON.stringify({ ...out, appid: undefined })
|
||||||
|
expect(serialized.includes('appid')).toBe(false)
|
||||||
|
expect(serialized.toLowerCase().includes('"appid"')).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('buildEntryPayload - scalar filtering', () => {
|
||||||
|
it('drops empty-string, null, and undefined scalars', () => {
|
||||||
|
const out = buildEntryPayload({
|
||||||
|
Name: 'x', Type: 'MSI', Installer: '', InstallArgs: null,
|
||||||
|
Script: undefined, LogFile: 'setup.log',
|
||||||
|
})
|
||||||
|
expect('Installer' in out).toBe(false)
|
||||||
|
expect('InstallArgs' in out).toBe(false)
|
||||||
|
expect('Script' in out).toBe(false)
|
||||||
|
expect(out.LogFile).toBe('setup.log')
|
||||||
|
})
|
||||||
|
it('keeps the underscore-prefixed scalars (_CmmVersion, _comment)', () => {
|
||||||
|
const out = buildEntryPayload({
|
||||||
|
Name: 'x', Type: 'MSI', _CmmVersion: '2019', _comment: 'note',
|
||||||
|
})
|
||||||
|
expect(out._CmmVersion).toBe('2019')
|
||||||
|
expect(out._comment).toBe('note')
|
||||||
|
})
|
||||||
|
it('only emits DetectionMethod when set', () => {
|
||||||
|
expect('DetectionMethod' in buildEntryPayload({ Name: 'x', Type: 'MSI', DetectionMethod: '' })).toBe(false)
|
||||||
|
expect(buildEntryPayload({ Name: 'x', Type: 'MSI', DetectionMethod: 'FileVersion' }).DetectionMethod).toBe('FileVersion')
|
||||||
|
})
|
||||||
|
it('drops a zero/falsy WaitTimeoutSec but keeps a real one', () => {
|
||||||
|
expect('WaitTimeoutSec' in buildEntryPayload({ Name: 'x', Type: 'MSI', WaitTimeoutSec: 0 })).toBe(false)
|
||||||
|
expect(buildEntryPayload({ Name: 'x', Type: 'MSI', WaitTimeoutSec: 300 }).WaitTimeoutSec).toBe(300)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('buildEntryPayload - RegValue coercion', () => {
|
||||||
|
it('coerces DWord to a Number', () => {
|
||||||
|
const out = buildEntryPayload({ Name: 'x', Type: 'Registry', RegType: 'DWord', RegValue: '1' })
|
||||||
|
expect(out.RegValue).toBe(1)
|
||||||
|
expect(typeof out.RegValue).toBe('number')
|
||||||
|
})
|
||||||
|
it('coerces QWord to a Number', () => {
|
||||||
|
const out = buildEntryPayload({ Name: 'x', Type: 'Registry', RegType: 'QWord', RegValue: '42' })
|
||||||
|
expect(out.RegValue).toBe(42)
|
||||||
|
})
|
||||||
|
it('leaves String reg values as strings', () => {
|
||||||
|
const out = buildEntryPayload({ Name: 'x', Type: 'Registry', RegType: 'String', RegValue: 'hello' })
|
||||||
|
expect(out.RegValue).toBe('hello')
|
||||||
|
expect(typeof out.RegValue).toBe('string')
|
||||||
|
})
|
||||||
|
it('does not emit RegValue for a non-Registry type', () => {
|
||||||
|
const out = buildEntryPayload({ Name: 'x', Type: 'MSI', RegType: 'DWord', RegValue: '1' })
|
||||||
|
expect('RegValue' in out).toBe(false)
|
||||||
|
})
|
||||||
|
it('drops an empty RegValue even for Registry', () => {
|
||||||
|
const out = buildEntryPayload({ Name: 'x', Type: 'Registry', RegType: 'DWord', RegValue: '' })
|
||||||
|
expect('RegValue' in out).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('buildEntryPayload - comma lists to arrays', () => {
|
||||||
|
it('splits PCTypes / TargetHostnames / TargetMachineNumbers', () => {
|
||||||
|
const out = buildEntryPayload({
|
||||||
|
Name: 'x', Type: 'MSI',
|
||||||
|
PCTypes: 'gea-shopfloor-cmm, gea-shopfloor-collections',
|
||||||
|
TargetHostnames: 'host-a, host-b',
|
||||||
|
TargetMachineNumbers: '0101, 0102',
|
||||||
|
})
|
||||||
|
expect(out.PCTypes).toEqual(['gea-shopfloor-cmm', 'gea-shopfloor-collections'])
|
||||||
|
expect(out.TargetHostnames).toEqual(['host-a', 'host-b'])
|
||||||
|
expect(out.TargetMachineNumbers).toEqual(['0101', '0102'])
|
||||||
|
})
|
||||||
|
it('omits the array keys when the list is empty', () => {
|
||||||
|
const out = buildEntryPayload({ Name: 'x', Type: 'MSI', PCTypes: '', TargetHostnames: ' ' })
|
||||||
|
expect('PCTypes' in out).toBe(false)
|
||||||
|
expect('TargetHostnames' in out).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('buildEntryPayload - preinstall flags', () => {
|
||||||
|
it('emits only the truthy flags', () => {
|
||||||
|
const out = buildEntryPayload({
|
||||||
|
Name: 'x', Type: 'MSI',
|
||||||
|
PreEnrollment: true, KillAfterDetection: false, PCTypesStrict: true,
|
||||||
|
})
|
||||||
|
expect(out.PreEnrollment).toBe(true)
|
||||||
|
expect(out.PCTypesStrict).toBe(true)
|
||||||
|
expect('KillAfterDetection' in out).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('buildEntryPayload - InUseCheck processes', () => {
|
||||||
|
it('carries Name, optional ExePath, and a numeric timeout per process', () => {
|
||||||
|
const out = buildEntryPayload({
|
||||||
|
Name: 'x', Type: 'MSI', inuseBehavior: 'CloseAndReopen',
|
||||||
|
inuseProcesses: [
|
||||||
|
{ name: 'pcdmis', exepath: 'C:\\pcd\\pcdmis.exe', timeout: 30 },
|
||||||
|
{ name: 'nodmis', exepath: '', timeout: null },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
expect(out.InUseCheck.Behavior).toBe('CloseAndReopen')
|
||||||
|
expect(out.InUseCheck.Processes).toEqual([
|
||||||
|
{ Name: 'pcdmis', ExePath: 'C:\\pcd\\pcdmis.exe', GracefulCloseTimeoutSec: 30 },
|
||||||
|
{ Name: 'nodmis' },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
it('drops processes with no name', () => {
|
||||||
|
const out = buildEntryPayload({
|
||||||
|
Name: 'x', Type: 'MSI', inuseBehavior: 'ForceClose',
|
||||||
|
inuseProcesses: [{ name: '', exepath: 'x', timeout: 5 }, { name: 'keep', timeout: 5 }],
|
||||||
|
})
|
||||||
|
expect(out.InUseCheck.Processes).toEqual([{ Name: 'keep', GracefulCloseTimeoutSec: 5 }])
|
||||||
|
})
|
||||||
|
it('coerces a string timeout to a number', () => {
|
||||||
|
const out = buildEntryPayload({
|
||||||
|
Name: 'x', Type: 'MSI', inuseBehavior: 'Defer',
|
||||||
|
inuseProcesses: [{ name: 'p', exepath: '', timeout: '15' }],
|
||||||
|
})
|
||||||
|
expect(out.InUseCheck.Processes[0].GracefulCloseTimeoutSec).toBe(15)
|
||||||
|
})
|
||||||
|
it('emits no InUseCheck when there is no behavior', () => {
|
||||||
|
const out = buildEntryPayload({ Name: 'x', Type: 'MSI', inuseBehavior: '' })
|
||||||
|
expect('InUseCheck' in out).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('buildEntryPayload - a blank new entry round-trips', () => {
|
||||||
|
it('produces a minimal body from blankEntry (name/type + null appid + default RegType)', () => {
|
||||||
|
const out = buildEntryPayload({ ...blankEntry(), Name: 'Fresh' })
|
||||||
|
// Type MSI, appid null. RegType is default String but non-Registry, so it
|
||||||
|
// is still carried as a scalar (it is in the scalar list). Confirm the shape.
|
||||||
|
expect(out.Name).toBe('Fresh')
|
||||||
|
expect(out.Type).toBe('MSI')
|
||||||
|
expect(out.appid).toBeNull()
|
||||||
|
expect('InUseCheck' in out).toBe(false)
|
||||||
|
expect('PCTypes' in out).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('availableEntryTypes', () => {
|
||||||
|
it('returns the full list for a non-preinstall scope', () => {
|
||||||
|
expect(availableEntryTypes(false, 'File')).toEqual(ENTRY_TYPES)
|
||||||
|
})
|
||||||
|
it('restricts to MSI/EXE for preinstall', () => {
|
||||||
|
expect(availableEntryTypes(true, 'MSI')).toEqual(['MSI', 'EXE'])
|
||||||
|
})
|
||||||
|
it('keeps an out-of-set current value visible in preinstall', () => {
|
||||||
|
expect(availableEntryTypes(true, 'Registry')).toEqual(['MSI', 'EXE', 'Registry'])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('availableDetectionMethods', () => {
|
||||||
|
it('returns the full list for a non-preinstall scope', () => {
|
||||||
|
expect(availableDetectionMethods(false, 'Hash')).toEqual(DETECTION_METHODS)
|
||||||
|
})
|
||||||
|
it('restricts to Registry/File for preinstall', () => {
|
||||||
|
expect(availableDetectionMethods(true, 'File')).toEqual(['Registry', 'File'])
|
||||||
|
})
|
||||||
|
it('keeps an out-of-set current value visible in preinstall', () => {
|
||||||
|
expect(availableDetectionMethods(true, 'FileVersion')).toEqual(['Registry', 'File', 'FileVersion'])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('targetingGates', () => {
|
||||||
|
it('defaults to pctypes-only when there is no scope', () => {
|
||||||
|
expect(targetingGates(null)).toEqual({
|
||||||
|
pctypes: true, cmmversion: false, machinenumbers: false, hostnames: false,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
it('shows pctypes for a fleetwide/common/preinstall scope', () => {
|
||||||
|
expect(targetingGates({ scopename: 'common', entries: [] }).pctypes).toBe(true)
|
||||||
|
expect(targetingGates({ scopename: 'x', phase: 'preinstall', entries: [] }).pctypes).toBe(true)
|
||||||
|
expect(targetingGates({ scopename: 'x', iscommon: true, entries: [] }).pctypes).toBe(true)
|
||||||
|
})
|
||||||
|
it('shows the cmm gate for a cmm-named scope', () => {
|
||||||
|
expect(targetingGates({ scopename: 'gea-shopfloor-cmm', entries: [] }).cmmversion).toBe(true)
|
||||||
|
})
|
||||||
|
it('does not name-match machinenumbers (data-driven only)', () => {
|
||||||
|
// 'nocollections' must not trip a loose name match; machinenumbers is purely
|
||||||
|
// data-driven off the entries.
|
||||||
|
const gates = targetingGates({ scopename: 'nocollections', entries: [] })
|
||||||
|
expect(gates.machinenumbers).toBe(false)
|
||||||
|
})
|
||||||
|
it('surfaces gates from entry data', () => {
|
||||||
|
const gates = targetingGates({
|
||||||
|
scopename: 'per-type', entries: [
|
||||||
|
{ PCTypes: ['a'] }, { TargetMachineNumbers: ['0101'] }, { TargetHostnames: ['h'] },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
expect(gates.pctypes).toBe(true)
|
||||||
|
expect(gates.machinenumbers).toBe(true)
|
||||||
|
expect(gates.hostnames).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('targetingHint', () => {
|
||||||
|
it('is empty with no scope', () => {
|
||||||
|
expect(targetingHint(null)).toBe('')
|
||||||
|
})
|
||||||
|
it('describes a fleet-wide common manifest', () => {
|
||||||
|
expect(targetingHint({ scopename: 'common' })).toMatch(/Fleet-wide/)
|
||||||
|
})
|
||||||
|
it('describes a preinstall manifest', () => {
|
||||||
|
expect(targetingHint({ scopename: 'x', phase: 'preinstall' })).toMatch(/Preinstall/)
|
||||||
|
})
|
||||||
|
it('names a per-type scope', () => {
|
||||||
|
expect(targetingHint({ scopename: 'gea-shopfloor-cmm' })).toMatch(/gea-shopfloor-cmm/)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('scopeSummary', () => {
|
||||||
|
it('is empty with no entries', () => {
|
||||||
|
expect(scopeSummary({ entries: [] })).toBe('')
|
||||||
|
expect(scopeSummary(null)).toBe('')
|
||||||
|
})
|
||||||
|
it('lists installer names and the entry count', () => {
|
||||||
|
const summary = scopeSummary({
|
||||||
|
entries: [
|
||||||
|
{ Type: 'MSI', Name: 'eDNC' },
|
||||||
|
{ Type: 'Registry', Name: 'reg' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
expect(summary).toBe('Installs eDNC. 2 entries; runs after common.')
|
||||||
|
})
|
||||||
|
it('truncates past six installers with a +N more', () => {
|
||||||
|
const entries = Array.from({ length: 8 }, (_, i) => ({ Type: 'MSI', Name: `app${i}` }))
|
||||||
|
const summary = scopeSummary({ entries })
|
||||||
|
expect(summary).toMatch(/\+2 more/)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('describeEntry', () => {
|
||||||
|
it('describes an MSI with FileVersion detection using the expected version', () => {
|
||||||
|
const text = describeEntry({ Type: 'MSI', Name: 'PC-DMIS', DetectionMethod: 'FileVersion', DetectionValue: '2019 R1' })
|
||||||
|
expect(text).toBe('Installs PC-DMIS; reinstalls unless version is 2019 R1')
|
||||||
|
})
|
||||||
|
it('says runs every cycle for Always / no detection', () => {
|
||||||
|
expect(describeEntry({ Type: 'CMD', Name: 'x' })).toBe('Runs x; runs every cycle')
|
||||||
|
expect(describeEntry({ Type: 'CMD', Name: 'x', DetectionMethod: 'Always' })).toBe('Runs x; runs every cycle')
|
||||||
|
})
|
||||||
|
it('appends a CMM gate note', () => {
|
||||||
|
const text = describeEntry({ Type: 'MSI', Name: 'x', _CmmVersion: '2016' })
|
||||||
|
expect(text).toMatch(/CMM 2016 bays only/)
|
||||||
|
})
|
||||||
|
it('appends the tracked app name when present', () => {
|
||||||
|
const text = describeEntry({ Type: 'MSI', Name: 'x', appname: 'PC-DMIS' })
|
||||||
|
expect(text).toMatch(/tracked: PC-DMIS/)
|
||||||
|
})
|
||||||
|
it('falls back to Applies for an unknown type', () => {
|
||||||
|
expect(describeEntry({ Type: 'Weird', Name: 'x' })).toMatch(/^Applies x/)
|
||||||
|
})
|
||||||
|
})
|
||||||
19
frontend/vitest.config.js
Normal file
19
frontend/vitest.config.js
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { defineConfig } from 'vitest/config'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
// Standalone vitest config so the unit runner stays independent of vite.config.js.
|
||||||
|
// jsdom gives component specs a DOM; the '@' alias mirrors the app build.
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, './src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
test: {
|
||||||
|
environment: 'jsdom',
|
||||||
|
globals: true,
|
||||||
|
include: ['src/**/*.spec.js'],
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -24,7 +24,7 @@ SHAREROOT_SETTING = 'geenforce_share_root'
|
|||||||
|
|
||||||
from ..models import (
|
from ..models import (
|
||||||
ManifestScope, ManifestEntry, ManifestPublishedVersion,
|
ManifestScope, ManifestEntry, ManifestPublishedVersion,
|
||||||
ManifestEnforcementReport, ENTRY_TYPES, PHASES,
|
ManifestEnforcementReport, ManifestPayload, ENTRY_TYPES, PHASES,
|
||||||
)
|
)
|
||||||
from ..serializer import scope_to_manifest, entry_to_dict
|
from ..serializer import scope_to_manifest, entry_to_dict
|
||||||
from ..importer import build_entry, populate_entry
|
from ..importer import build_entry, populate_entry
|
||||||
@@ -202,6 +202,12 @@ def _entry_payload(entry):
|
|||||||
app = db.session.get(Application, entry.appid)
|
app = db.session.get(Application, entry.appid)
|
||||||
data['appname'] = app.appname if app else None
|
data['appname'] = app.appname if app else None
|
||||||
data.update(entry_to_dict(entry))
|
data.update(entry_to_dict(entry))
|
||||||
|
# Inline-payload metadata (shopdb-only, NOT manifest keys) for the editor.
|
||||||
|
data['payloadsource'] = entry.payloadsource
|
||||||
|
data['payloadref'] = entry.payloadref
|
||||||
|
data['payloadsha256'] = entry.payloadsha256
|
||||||
|
data['haspayload'] = (entry.payloadsource == 'inline'
|
||||||
|
and entry.payloadsha256 is not None)
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
@@ -426,6 +432,74 @@ def simulate_scope(scopeid):
|
|||||||
'filtered': filtered})
|
'filtered': filtered})
|
||||||
|
|
||||||
|
|
||||||
|
# -- compliance: "how much of the fleet has this app" -------------------------
|
||||||
|
|
||||||
|
@geenforce_bp.route('/scopes/<int:scopeid>/compliance', methods=['GET'])
|
||||||
|
@jwt_required()
|
||||||
|
@require_permission('geenforce.manage')
|
||||||
|
def scope_compliance(scopeid):
|
||||||
|
"""Fleet-install coverage per app-linked entry (from collected PC data).
|
||||||
|
|
||||||
|
One row per entry that carries a curated appid; installed/version-match
|
||||||
|
counts come from the computers plugin's ComputerInstalledApp. Degrades to
|
||||||
|
null counts (computersplugin: false) when that plugin is absent.
|
||||||
|
"""
|
||||||
|
scope = db.session.get(ManifestScope, scopeid)
|
||||||
|
if not scope:
|
||||||
|
return error_response(ErrorCodes.NOT_FOUND, 'No such scope',
|
||||||
|
http_code=404)
|
||||||
|
return success_response(service.compliance_for_scope(scope))
|
||||||
|
|
||||||
|
|
||||||
|
# -- inline payload upload / fetch (small scripts + configs) ------------------
|
||||||
|
|
||||||
|
PAYLOAD_MAX_BYTES = 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
@geenforce_bp.route('/entries/<int:entryid>/payload', methods=['POST'])
|
||||||
|
@jwt_required()
|
||||||
|
@require_permission('geenforce.publish')
|
||||||
|
def upload_entry_payload(entryid):
|
||||||
|
"""Store an inline payload (<= 1 MB) for an entry and point the entry at it."""
|
||||||
|
entry = db.session.get(ManifestEntry, entryid)
|
||||||
|
if not entry:
|
||||||
|
return error_response(ErrorCodes.NOT_FOUND, 'No such entry', http_code=404)
|
||||||
|
uploaded = request.files.get('file')
|
||||||
|
if uploaded is None:
|
||||||
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||||
|
'a file is required', http_code=400)
|
||||||
|
rawbytes = uploaded.read()
|
||||||
|
if not rawbytes:
|
||||||
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||||
|
'payload is empty', http_code=400)
|
||||||
|
if len(rawbytes) > PAYLOAD_MAX_BYTES:
|
||||||
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||||
|
'payload exceeds 1 MB limit', http_code=400)
|
||||||
|
service.store_inline_payload(entry, uploaded.filename,
|
||||||
|
uploaded.mimetype, rawbytes)
|
||||||
|
db.session.commit()
|
||||||
|
return success_response(_entry_payload(entry), http_code=201)
|
||||||
|
|
||||||
|
|
||||||
|
@geenforce_bp.route('/entries/<int:entryid>/payload', methods=['GET'])
|
||||||
|
@jwt_required()
|
||||||
|
@require_permission('geenforce.manage')
|
||||||
|
def download_entry_payload(entryid):
|
||||||
|
"""Return the stored inline payload bytes for an entry (404 if none)."""
|
||||||
|
entry = db.session.get(ManifestEntry, entryid)
|
||||||
|
if not entry:
|
||||||
|
return error_response(ErrorCodes.NOT_FOUND, 'No such entry', http_code=404)
|
||||||
|
payload = ManifestPayload.query.filter_by(entryid=entryid).first()
|
||||||
|
if not payload:
|
||||||
|
return error_response(ErrorCodes.NOT_FOUND, 'entry has no payload',
|
||||||
|
http_code=404)
|
||||||
|
return Response(
|
||||||
|
payload.payloadbytes,
|
||||||
|
mimetype=payload.contenttype or 'application/octet-stream',
|
||||||
|
headers={'Content-Disposition':
|
||||||
|
f'attachment; filename="{payload.filename}"'})
|
||||||
|
|
||||||
|
|
||||||
# -- publish lifecycle (geenforce.publish) ------------------------------------
|
# -- publish lifecycle (geenforce.publish) ------------------------------------
|
||||||
|
|
||||||
@geenforce_bp.route('/scopes/<int:scopeid>/publish', methods=['POST'])
|
@geenforce_bp.route('/scopes/<int:scopeid>/publish', methods=['POST'])
|
||||||
|
|||||||
@@ -7,17 +7,18 @@ Kept out of the CLI and routes so both share one implementation:
|
|||||||
- `rollback_scope` / `export_scope_to_share` round out the publish lifecycle.
|
- `rollback_scope` / `export_scope_to_share` round out the publish lifecycle.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
import os
|
import os
|
||||||
import tempfile
|
import tempfile
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from sqlalchemy import func
|
from sqlalchemy import func
|
||||||
|
|
||||||
from shopdb.api import db
|
from shopdb.api import db, Application
|
||||||
|
|
||||||
from .models import (
|
from .models import (
|
||||||
ManifestScope, ManifestPublishedVersion, ManifestEnforcementReport,
|
ManifestScope, ManifestPublishedVersion, ManifestEnforcementReport,
|
||||||
ManifestEnforcementResult,
|
ManifestEnforcementResult, ManifestPayload,
|
||||||
)
|
)
|
||||||
from .importer import build_entry
|
from .importer import build_entry
|
||||||
from .serializer import scope_to_json
|
from .serializer import scope_to_json
|
||||||
@@ -27,6 +28,20 @@ def _utcnow():
|
|||||||
return datetime.now(timezone.utc).replace(tzinfo=None)
|
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||||
|
|
||||||
|
|
||||||
|
def _installed_app_model():
|
||||||
|
"""Lazily import the computers plugin's ComputerInstalledApp.
|
||||||
|
|
||||||
|
The computers plugin is optional; importing it lazily (not at module load)
|
||||||
|
keeps geenforce usable when computers is absent or disabled. Mirrors
|
||||||
|
collector._computer_models. Returns the model class, or None if absent.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from plugins.computers.models import ComputerInstalledApp
|
||||||
|
return ComputerInstalledApp
|
||||||
|
except ImportError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def replace_scope_draft(scopename, phase, manifest):
|
def replace_scope_draft(scopename, phase, manifest):
|
||||||
"""Create/refresh a scope and REPLACE its draft entries. Published versions
|
"""Create/refresh a scope and REPLACE its draft entries. Published versions
|
||||||
are left untouched. Returns the scope (uncommitted)."""
|
are left untouched. Returns the scope (uncommitted)."""
|
||||||
@@ -175,6 +190,111 @@ def _parse_dt(value):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def compliance_for_scope(scope):
|
||||||
|
"""Fleet-install coverage for a scope's app-linked entries.
|
||||||
|
|
||||||
|
One row per ManifestEntry that carries a curated appid (unlinked entries
|
||||||
|
are skipped), ordered by sortorder. Counts come from the computers plugin's
|
||||||
|
ComputerInstalledApp (active rows only, one row per PC per app). Degrades
|
||||||
|
gracefully with null counts when the computers plugin is absent.
|
||||||
|
|
||||||
|
Returns the response data dict (scopeid/scopename/phase/computersplugin/rows).
|
||||||
|
"""
|
||||||
|
linked = [e for e in sorted(scope.entries, key=lambda e: e.sortorder)
|
||||||
|
if e.appid is not None]
|
||||||
|
installedmodel = _installed_app_model()
|
||||||
|
computerspresent = installedmodel is not None
|
||||||
|
|
||||||
|
# One grouped query for the whole scope's appid set (fleet is tiny).
|
||||||
|
installedbyapp = {}
|
||||||
|
if computerspresent and linked:
|
||||||
|
appids = {e.appid for e in linked}
|
||||||
|
counted = db.session.query(
|
||||||
|
installedmodel.appid, func.count(installedmodel.id)
|
||||||
|
).filter(
|
||||||
|
installedmodel.isactive == True,
|
||||||
|
installedmodel.appid.in_(appids),
|
||||||
|
).group_by(installedmodel.appid).all()
|
||||||
|
installedbyapp = {appid: count for appid, count in counted}
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
for entry in linked:
|
||||||
|
app = db.session.get(Application, entry.appid)
|
||||||
|
appname = app.appname if app else None
|
||||||
|
expectedversion = (entry.detectionvalue
|
||||||
|
if entry.detectionmethod == 'FileVersion' else None)
|
||||||
|
|
||||||
|
if not computerspresent:
|
||||||
|
installedcount = None
|
||||||
|
versionmatchcount = None
|
||||||
|
coveragenote = ('computers plugin not installed; install counts '
|
||||||
|
'unavailable')
|
||||||
|
else:
|
||||||
|
installedcount = installedbyapp.get(entry.appid, 0)
|
||||||
|
if installedcount == 0:
|
||||||
|
versionmatchcount = None if expectedversion is None else 0
|
||||||
|
coveragenote = 'not installed on any collected PC'
|
||||||
|
elif expectedversion is None:
|
||||||
|
versionmatchcount = None
|
||||||
|
method = entry.detectionmethod or 'none'
|
||||||
|
coveragenote = (f'installed on {installedcount} PC(s); no '
|
||||||
|
f'version target (detection is {method})')
|
||||||
|
else:
|
||||||
|
versionmatchcount = db.session.query(
|
||||||
|
func.count(installedmodel.id)
|
||||||
|
).filter(
|
||||||
|
installedmodel.isactive == True,
|
||||||
|
installedmodel.appid == entry.appid,
|
||||||
|
installedmodel.installedversion == expectedversion,
|
||||||
|
).scalar() or 0
|
||||||
|
coveragenote = (f'{versionmatchcount} of {installedcount} '
|
||||||
|
'collected PCs on the expected version')
|
||||||
|
|
||||||
|
rows.append({
|
||||||
|
'entryid': entry.entryid,
|
||||||
|
'entryname': entry.name,
|
||||||
|
'appid': entry.appid,
|
||||||
|
'appname': appname,
|
||||||
|
'expectedversion': expectedversion,
|
||||||
|
'installedcount': installedcount,
|
||||||
|
'versionmatchcount': versionmatchcount,
|
||||||
|
'coveragenote': coveragenote,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
'scopeid': scope.scopeid,
|
||||||
|
'scopename': scope.scopename,
|
||||||
|
'phase': scope.phase,
|
||||||
|
'computersplugin': computerspresent,
|
||||||
|
'rows': rows,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def store_inline_payload(entry, filename, contenttype, rawbytes):
|
||||||
|
"""Replace the entry's inline payload with these bytes (uncommitted).
|
||||||
|
|
||||||
|
Computes payloadsha256, upserts the single ManifestPayload row (the table
|
||||||
|
has no unique constraint on entryid, so the one-inline-payload-per-entry
|
||||||
|
rule is enforced here), and points the entry at it (payloadsource='inline').
|
||||||
|
Returns the ManifestPayload.
|
||||||
|
"""
|
||||||
|
sha = hashlib.sha256(rawbytes).hexdigest()
|
||||||
|
ManifestPayload.query.filter_by(entryid=entry.entryid).delete()
|
||||||
|
db.session.flush()
|
||||||
|
payload = ManifestPayload(
|
||||||
|
entryid=entry.entryid,
|
||||||
|
filename=filename,
|
||||||
|
contenttype=contenttype,
|
||||||
|
payloadbytes=rawbytes,
|
||||||
|
payloadsha256=sha,
|
||||||
|
uploadedat=_utcnow())
|
||||||
|
db.session.add(payload)
|
||||||
|
entry.payloadsource = 'inline'
|
||||||
|
entry.payloadref = filename
|
||||||
|
entry.payloadsha256 = sha
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
def export_scope_to_share(scopename, phase, shareroot):
|
def export_scope_to_share(scopename, phase, shareroot):
|
||||||
"""Write a scope's current published JSON to the share, backing up the old
|
"""Write a scope's current published JSON to the share, backing up the old
|
||||||
file to _meta/history first. Returns the written path."""
|
file to _meta/history first. Returns the written path."""
|
||||||
|
|||||||
166
tests/test_plugins/test_geenforce_compliance.py
Normal file
166
tests/test_plugins/test_geenforce_compliance.py
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
"""GE-Enforce compliance: fleet-install coverage per app-linked entry.
|
||||||
|
|
||||||
|
Compliance pairs each app-linked manifest entry with collected PC data
|
||||||
|
(ComputerInstalledApp) to answer 'how many PCs have this app, and how many on
|
||||||
|
the expected version'. Only entries carrying a curated appid appear; unlinked
|
||||||
|
entries are skipped. FileVersion detection supplies the expected-version target;
|
||||||
|
every other detection method yields a null target (no meaningful match). When
|
||||||
|
the optional computers plugin is absent, counts degrade to null gracefully.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from shopdb.core.models import Asset, AssetType, Application
|
||||||
|
|
||||||
|
from plugins.geenforce import service
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_app(db, appname):
|
||||||
|
app_row = Application(appname=appname)
|
||||||
|
db.session.add(app_row)
|
||||||
|
db.session.commit()
|
||||||
|
return app_row.appid
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_pc_with_app(db, hostname, appid, installedversion, isactive=True):
|
||||||
|
"""Create a Computer + one ComputerInstalledApp row for it."""
|
||||||
|
from plugins.computers.models import Computer, ComputerInstalledApp
|
||||||
|
atype = AssetType.query.filter_by(assettype='computer').first()
|
||||||
|
if not atype:
|
||||||
|
atype = AssetType(assettype='computer')
|
||||||
|
db.session.add(atype)
|
||||||
|
db.session.flush()
|
||||||
|
asset = Asset(assetnumber=f'PC-{hostname}', assettypeid=atype.assettypeid)
|
||||||
|
db.session.add(asset)
|
||||||
|
db.session.flush()
|
||||||
|
comp = Computer(assetid=asset.assetid, hostname=hostname)
|
||||||
|
db.session.add(comp)
|
||||||
|
db.session.flush()
|
||||||
|
link = ComputerInstalledApp(computerid=comp.computerid, appid=appid,
|
||||||
|
installedversion=installedversion,
|
||||||
|
isactive=isactive)
|
||||||
|
db.session.add(link)
|
||||||
|
db.session.commit()
|
||||||
|
return comp
|
||||||
|
|
||||||
|
|
||||||
|
def _create_scope(client, auth_headers, name='gea-shopfloor-cmm'):
|
||||||
|
resp = client.post('/api/geenforce/scopes',
|
||||||
|
json={'scopename': name, 'phase': 'runtime'},
|
||||||
|
headers=auth_headers)
|
||||||
|
assert resp.status_code == 201, resp.get_json()
|
||||||
|
return resp.get_json()['data']['scopeid']
|
||||||
|
|
||||||
|
|
||||||
|
def _add_entry(client, auth_headers, scopeid, entry):
|
||||||
|
resp = client.post(f'/api/geenforce/scopes/{scopeid}/entries',
|
||||||
|
json=entry, headers=auth_headers)
|
||||||
|
assert resp.status_code == 201, resp.get_json()
|
||||||
|
return resp.get_json()['data']['entryid']
|
||||||
|
|
||||||
|
|
||||||
|
def test_compliance_only_lists_app_linked_entries(client, db, auth_headers):
|
||||||
|
appid = _seed_app(db, 'PC-DMIS')
|
||||||
|
scopeid = _create_scope(client, auth_headers)
|
||||||
|
_add_entry(client, auth_headers, scopeid,
|
||||||
|
{'Name': 'PC-DMIS 2019', 'Type': 'MSI', 'appid': appid,
|
||||||
|
'DetectionMethod': 'FileVersion', 'DetectionPath': 'C:\\p.exe',
|
||||||
|
'DetectionValue': '2019 R1'})
|
||||||
|
# An unlinked entry (no appid) must NOT appear.
|
||||||
|
_add_entry(client, auth_headers, scopeid,
|
||||||
|
{'Name': 'Unlinked', 'Type': 'MSI', 'Installer': 'apps/x.msi'})
|
||||||
|
|
||||||
|
data = client.get(f'/api/geenforce/scopes/{scopeid}/compliance',
|
||||||
|
headers=auth_headers).get_json()['data']
|
||||||
|
assert data['computersplugin'] is True
|
||||||
|
assert [r['entryname'] for r in data['rows']] == ['PC-DMIS 2019']
|
||||||
|
row = data['rows'][0]
|
||||||
|
assert row['appid'] == appid
|
||||||
|
assert row['appname'] == 'PC-DMIS'
|
||||||
|
assert row['expectedversion'] == '2019 R1'
|
||||||
|
|
||||||
|
|
||||||
|
def test_compliance_counts_active_installs_and_version_match(client, db,
|
||||||
|
auth_headers):
|
||||||
|
appid = _seed_app(db, 'PC-DMIS')
|
||||||
|
# Two PCs on the expected version, one on a different version, one inactive.
|
||||||
|
_seed_pc_with_app(db, 'CMM01', appid, '2019 R1')
|
||||||
|
_seed_pc_with_app(db, 'CMM02', appid, '2019 R1')
|
||||||
|
_seed_pc_with_app(db, 'CMM03', appid, '2016 R2')
|
||||||
|
_seed_pc_with_app(db, 'CMM04', appid, '2019 R1', isactive=False)
|
||||||
|
|
||||||
|
scopeid = _create_scope(client, auth_headers)
|
||||||
|
_add_entry(client, auth_headers, scopeid,
|
||||||
|
{'Name': 'PC-DMIS 2019', 'Type': 'MSI', 'appid': appid,
|
||||||
|
'DetectionMethod': 'FileVersion', 'DetectionPath': 'C:\\p.exe',
|
||||||
|
'DetectionValue': '2019 R1'})
|
||||||
|
|
||||||
|
row = client.get(f'/api/geenforce/scopes/{scopeid}/compliance',
|
||||||
|
headers=auth_headers).get_json()['data']['rows'][0]
|
||||||
|
# Inactive row excluded: 3 active installs, 2 on the expected version.
|
||||||
|
assert row['installedcount'] == 3
|
||||||
|
assert row['versionmatchcount'] == 2
|
||||||
|
assert '2 of 3' in row['coveragenote']
|
||||||
|
|
||||||
|
|
||||||
|
def test_compliance_non_fileversion_has_null_version_target(client, db,
|
||||||
|
auth_headers):
|
||||||
|
appid = _seed_app(db, 'eDNC')
|
||||||
|
_seed_pc_with_app(db, 'DNC01', appid, '5.0')
|
||||||
|
scopeid = _create_scope(client, auth_headers)
|
||||||
|
# Registry detection is not a version string -> no expected-version target.
|
||||||
|
_add_entry(client, auth_headers, scopeid,
|
||||||
|
{'Name': 'eDNC', 'Type': 'MSI', 'appid': appid,
|
||||||
|
'DetectionMethod': 'Registry',
|
||||||
|
'DetectionPath': 'HKLM\\Software\\eDNC'})
|
||||||
|
|
||||||
|
row = client.get(f'/api/geenforce/scopes/{scopeid}/compliance',
|
||||||
|
headers=auth_headers).get_json()['data']['rows'][0]
|
||||||
|
assert row['expectedversion'] is None
|
||||||
|
assert row['installedcount'] == 1
|
||||||
|
assert row['versionmatchcount'] is None
|
||||||
|
assert 'no version target' in row['coveragenote']
|
||||||
|
|
||||||
|
|
||||||
|
def test_compliance_not_installed_anywhere(client, db, auth_headers):
|
||||||
|
appid = _seed_app(db, 'Orphan')
|
||||||
|
scopeid = _create_scope(client, auth_headers)
|
||||||
|
_add_entry(client, auth_headers, scopeid,
|
||||||
|
{'Name': 'Orphan', 'Type': 'MSI', 'appid': appid,
|
||||||
|
'DetectionMethod': 'FileVersion', 'DetectionPath': 'C:\\o.exe',
|
||||||
|
'DetectionValue': '1.0'})
|
||||||
|
|
||||||
|
row = client.get(f'/api/geenforce/scopes/{scopeid}/compliance',
|
||||||
|
headers=auth_headers).get_json()['data']['rows'][0]
|
||||||
|
assert row['installedcount'] == 0
|
||||||
|
assert row['versionmatchcount'] == 0
|
||||||
|
assert row['coveragenote'] == 'not installed on any collected PC'
|
||||||
|
|
||||||
|
|
||||||
|
def test_compliance_scope_not_found(client, db, auth_headers):
|
||||||
|
resp = client.get('/api/geenforce/scopes/999999/compliance',
|
||||||
|
headers=auth_headers)
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_compliance_requires_manage_permission(client, db, member_headers):
|
||||||
|
resp = client.get('/api/geenforce/scopes/1/compliance',
|
||||||
|
headers=member_headers)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_compliance_graceful_without_computers_plugin(client, db, auth_headers,
|
||||||
|
monkeypatch):
|
||||||
|
appid = _seed_app(db, 'PC-DMIS')
|
||||||
|
scopeid = _create_scope(client, auth_headers)
|
||||||
|
_add_entry(client, auth_headers, scopeid,
|
||||||
|
{'Name': 'PC-DMIS 2019', 'Type': 'MSI', 'appid': appid,
|
||||||
|
'DetectionMethod': 'FileVersion', 'DetectionPath': 'C:\\p.exe',
|
||||||
|
'DetectionValue': '2019 R1'})
|
||||||
|
monkeypatch.setattr(service, '_installed_app_model', lambda: None)
|
||||||
|
|
||||||
|
data = client.get(f'/api/geenforce/scopes/{scopeid}/compliance',
|
||||||
|
headers=auth_headers).get_json()['data']
|
||||||
|
assert data['computersplugin'] is False
|
||||||
|
row = data['rows'][0]
|
||||||
|
assert row['installedcount'] is None
|
||||||
|
assert row['versionmatchcount'] is None
|
||||||
|
assert 'computers plugin not installed' in row['coveragenote']
|
||||||
125
tests/test_plugins/test_geenforce_payload.py
Normal file
125
tests/test_plugins/test_geenforce_payload.py
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
"""GE-Enforce inline payloads: upload small scripts/configs into shopdb.
|
||||||
|
|
||||||
|
An entry can carry one inline payload (<= 1 MB) stored as ManifestPayload bytes.
|
||||||
|
Upload computes a sha256, sets the entry's payloadsource='inline' + payloadref,
|
||||||
|
and enforces one payload row per entry (re-upload replaces). GET returns the
|
||||||
|
raw bytes. Upload needs geenforce.publish; GET needs geenforce.manage. appid and
|
||||||
|
the payload metadata are shopdb-only and never enter the served manifest JSON.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import io
|
||||||
|
|
||||||
|
from plugins.geenforce.models import ManifestPayload
|
||||||
|
|
||||||
|
|
||||||
|
def _create_scope(client, auth_headers, name='gea-shopfloor-cmm'):
|
||||||
|
resp = client.post('/api/geenforce/scopes',
|
||||||
|
json={'scopename': name, 'phase': 'runtime'},
|
||||||
|
headers=auth_headers)
|
||||||
|
assert resp.status_code == 201, resp.get_json()
|
||||||
|
return resp.get_json()['data']['scopeid']
|
||||||
|
|
||||||
|
|
||||||
|
def _add_entry(client, auth_headers, scopeid, name='eDNC install'):
|
||||||
|
resp = client.post(f'/api/geenforce/scopes/{scopeid}/entries',
|
||||||
|
json={'Name': name, 'Type': 'PS1',
|
||||||
|
'Script': 'scripts/x.ps1'},
|
||||||
|
headers=auth_headers)
|
||||||
|
assert resp.status_code == 201, resp.get_json()
|
||||||
|
return resp.get_json()['data']['entryid']
|
||||||
|
|
||||||
|
|
||||||
|
def _upload(client, headers, entryid, content, filename='config.reg',
|
||||||
|
content_type='multipart/form-data'):
|
||||||
|
return client.post(
|
||||||
|
f'/api/geenforce/entries/{entryid}/payload',
|
||||||
|
data={'file': (io.BytesIO(content), filename)},
|
||||||
|
content_type=content_type, headers=headers)
|
||||||
|
|
||||||
|
|
||||||
|
def test_upload_stores_payload_and_links_entry(client, db, auth_headers):
|
||||||
|
scopeid = _create_scope(client, auth_headers)
|
||||||
|
entryid = _add_entry(client, auth_headers, scopeid)
|
||||||
|
content = b'Windows Registry Editor Version 5.00\r\n'
|
||||||
|
|
||||||
|
resp = _upload(client, auth_headers, entryid, content)
|
||||||
|
assert resp.status_code == 201, resp.get_json()
|
||||||
|
data = resp.get_json()['data']
|
||||||
|
assert data['payloadsource'] == 'inline'
|
||||||
|
assert data['payloadref'] == 'config.reg'
|
||||||
|
assert data['payloadsha256'] == hashlib.sha256(content).hexdigest()
|
||||||
|
assert data['haspayload'] is True
|
||||||
|
|
||||||
|
rows = ManifestPayload.query.filter_by(entryid=entryid).all()
|
||||||
|
assert len(rows) == 1
|
||||||
|
assert rows[0].payloadbytes == content
|
||||||
|
|
||||||
|
|
||||||
|
def test_reupload_replaces_single_row(client, db, auth_headers):
|
||||||
|
scopeid = _create_scope(client, auth_headers)
|
||||||
|
entryid = _add_entry(client, auth_headers, scopeid)
|
||||||
|
_upload(client, auth_headers, entryid, b'first')
|
||||||
|
newcontent = b'second version'
|
||||||
|
resp = _upload(client, auth_headers, entryid, newcontent)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
|
||||||
|
rows = ManifestPayload.query.filter_by(entryid=entryid).all()
|
||||||
|
assert len(rows) == 1
|
||||||
|
assert rows[0].payloadbytes == newcontent
|
||||||
|
assert rows[0].payloadsha256 == hashlib.sha256(newcontent).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def test_oversized_payload_rejected(client, db, auth_headers):
|
||||||
|
scopeid = _create_scope(client, auth_headers)
|
||||||
|
entryid = _add_entry(client, auth_headers, scopeid)
|
||||||
|
toobig = b'x' * (1024 * 1024 + 1)
|
||||||
|
resp = _upload(client, auth_headers, entryid, toobig)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert ManifestPayload.query.filter_by(entryid=entryid).count() == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_and_missing_file_rejected(client, db, auth_headers):
|
||||||
|
scopeid = _create_scope(client, auth_headers)
|
||||||
|
entryid = _add_entry(client, auth_headers, scopeid)
|
||||||
|
empty = _upload(client, auth_headers, entryid, b'')
|
||||||
|
assert empty.status_code == 400
|
||||||
|
nofile = client.post(f'/api/geenforce/entries/{entryid}/payload',
|
||||||
|
data={}, content_type='multipart/form-data',
|
||||||
|
headers=auth_headers)
|
||||||
|
assert nofile.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_download_returns_exact_bytes(client, db, auth_headers):
|
||||||
|
scopeid = _create_scope(client, auth_headers)
|
||||||
|
entryid = _add_entry(client, auth_headers, scopeid)
|
||||||
|
content = b'\x00\x01binary payload\xff'
|
||||||
|
_upload(client, auth_headers, entryid, content, filename='blob.bin')
|
||||||
|
|
||||||
|
resp = client.get(f'/api/geenforce/entries/{entryid}/payload',
|
||||||
|
headers=auth_headers)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.data == content
|
||||||
|
assert 'blob.bin' in resp.headers['Content-Disposition']
|
||||||
|
|
||||||
|
|
||||||
|
def test_download_404_when_no_payload(client, db, auth_headers):
|
||||||
|
scopeid = _create_scope(client, auth_headers)
|
||||||
|
entryid = _add_entry(client, auth_headers, scopeid)
|
||||||
|
resp = client.get(f'/api/geenforce/entries/{entryid}/payload',
|
||||||
|
headers=auth_headers)
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_upload_404_unknown_entry(client, db, auth_headers):
|
||||||
|
resp = _upload(client, auth_headers, 999999, b'data')
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_upload_requires_publish_permission(client, db, auth_headers,
|
||||||
|
member_headers):
|
||||||
|
scopeid = _create_scope(client, auth_headers)
|
||||||
|
entryid = _add_entry(client, auth_headers, scopeid)
|
||||||
|
# member (no permissions) cannot upload.
|
||||||
|
resp = _upload(client, member_headers, entryid, b'data')
|
||||||
|
assert resp.status_code == 403
|
||||||
Reference in New Issue
Block a user