Relocate applications, geenforce, knowledgebase, and machines - each owns only its own views dir, so a clean move to plugins/<name>/frontend/ (views/ + routes.js, core imports rewritten to @/). geenforce's entryForm.js helper + its vitest spec move with it (ManifestEditor imports it as a sibling). Machinery fixes this batch surfaced: - routes.gen.js codegen uses namespace imports (import * as p_x). A route file without a `toplevel` export is undefined on the namespace instead of a strict- ESM missing-binding build error. - vitest gains a `pretest` stage so plugin-frontend specs (now under plugins/<name>/frontend/) run from their staged copy in src/.plugins-staged/. Verified live: GE-Enforce (the most complex, uses the entryForm sibling helper) renders fully from its staged frontend. Build + 58 vitest + naming green.
322 lines
13 KiB
JavaScript
322 lines
13 KiB
JavaScript
import { describe, it, expect } from 'vitest'
|
|
import {
|
|
blankEntry,
|
|
splitList,
|
|
buildEntryPayload,
|
|
availableEntryTypes,
|
|
availableDetectionMethods,
|
|
targetingGates,
|
|
targetingHint,
|
|
scopeSummary,
|
|
describeEntry,
|
|
detectionMethodHint,
|
|
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/)
|
|
})
|
|
})
|
|
|
|
describe('detectionMethodHint', () => {
|
|
it('has a non-empty hint for every detection method', () => {
|
|
for (const method of DETECTION_METHODS) {
|
|
expect(detectionMethodHint(method).length).toBeGreaterThan(0)
|
|
}
|
|
})
|
|
it('describes the no-detection case for empty/undefined', () => {
|
|
expect(detectionMethodHint('')).toMatch(/every cycle/)
|
|
expect(detectionMethodHint(undefined)).toMatch(/every cycle/)
|
|
})
|
|
it('ties FileVersion to the compliance panel', () => {
|
|
expect(detectionMethodHint('FileVersion')).toMatch(/Compliance/)
|
|
})
|
|
it('returns empty string for an unknown method', () => {
|
|
expect(detectionMethodHint('Nonsense')).toBe('')
|
|
})
|
|
})
|