Updates
This commit is contained in:
1 parent
d27811254d
commit
582d0e635d
35 files changed
+2944
-1203
No files matched your search
@@ -6,7 +6,8 @@ const { createHost, listHosts, updateHost, deleteHost, filterVisibleHostIds } =
|
||||
const { createCredential } = await load('../models/credentialModel.js');
|
||||
const { createHostGroup, listHostGroups, syncHostGroup } = await load('../models/hostGroupModel.js');
|
||||
const { createRunPlan, loadRunPlan } = await load('../models/runPlanModel.js');
|
||||
const { createRdpLaunchSession } = await load('../services/rdpGatewayService.js');
|
||||
const { createRdpLaunchSession, buildMyrtilleLaunchUrl } = await load('../services/rdpGatewayService.js');
|
||||
const { updateSettings } = await load('../models/settingsModel.js');
|
||||
const { db } = await load('../db.js');
|
||||
|
||||
const owner = adminUserId();
|
||||
@@ -54,7 +55,15 @@ test('filterVisibleHostIds drops hosts the user cannot see', () => {
|
||||
assert.deepEqual(visibleToOther, [shared.id]);
|
||||
});
|
||||
|
||||
test('RDP launch creates a short-lived browser session for Windows hosts with visible credentials', () => {
|
||||
test('RDP launch creates a Myrtille browser URL for Windows hosts with visible credentials', async () => {
|
||||
updateSettings({
|
||||
myrtille_enabled: 'true',
|
||||
myrtille_gateway_url: 'https://rdp.contoso.local/Myrtille/',
|
||||
myrtille_use_password_hash: 'false',
|
||||
myrtille_allow_plain_password: 'true',
|
||||
myrtille_default_width: '1440',
|
||||
myrtille_default_height: '900'
|
||||
});
|
||||
const credential = createCredential({
|
||||
name: 'RDP Admin',
|
||||
kind: 'username_password',
|
||||
@@ -72,14 +81,28 @@ test('RDP launch creates a short-lived browser session for Windows hosts with vi
|
||||
tags: [],
|
||||
visibility: 'shared'
|
||||
}, owner);
|
||||
const session = createRdpLaunchSession(host.id, other);
|
||||
assert.match(session.url, /^\/rdp\/[^/]+$/);
|
||||
const session = await createRdpLaunchSession(host.id, other);
|
||||
const url = new URL(session.url);
|
||||
assert.equal(session.provider, 'myrtille');
|
||||
assert.equal(url.origin + url.pathname, 'https://rdp.contoso.local/Myrtille/');
|
||||
assert.equal(url.searchParams.get('server'), 'rdp-win-01.contoso.local');
|
||||
assert.equal(url.searchParams.get('domain'), 'CONTOSO');
|
||||
assert.equal(url.searchParams.get('user'), 'rdpadmin');
|
||||
assert.equal(url.searchParams.get('password'), 'rdp-secret');
|
||||
assert.equal(url.searchParams.get('width'), '1440');
|
||||
assert.equal(url.searchParams.get('height'), '900');
|
||||
assert.equal(url.searchParams.get('connect'), 'Connect!');
|
||||
assert.equal(session.host.name, 'RDP-WIN-01');
|
||||
assert.equal(session.host.address, 'rdp-win-01.contoso.local');
|
||||
assert.ok(!session.url.includes('rdp-secret'));
|
||||
});
|
||||
|
||||
test('RDP launch rejects non-Windows hosts or hosts without credentials', () => {
|
||||
test('RDP launch rejects non-Windows hosts, hosts without credentials, or disabled Myrtille config', async () => {
|
||||
updateSettings({
|
||||
myrtille_enabled: 'true',
|
||||
myrtille_gateway_url: 'https://rdp.contoso.local/Myrtille/',
|
||||
myrtille_use_password_hash: 'false',
|
||||
myrtille_allow_plain_password: 'true'
|
||||
});
|
||||
const credential = createCredential({
|
||||
name: 'RDP Linux Credential',
|
||||
kind: 'username_password',
|
||||
@@ -88,10 +111,29 @@ test('RDP launch rejects non-Windows hosts or hosts without credentials', () =>
|
||||
visibility: 'shared'
|
||||
}, owner);
|
||||
const linux = createHost({ name: 'RDP-LINUX-01', address: 'rdp-linux.local', osFamily: 'linux', transport: 'ssh', credentialId: credential.id, tags: [], visibility: 'shared' }, owner);
|
||||
assert.throws(() => createRdpLaunchSession(linux.id, owner), /Windows hosts/i);
|
||||
await assert.rejects(() => createRdpLaunchSession(linux.id, owner), /Windows hosts/i);
|
||||
|
||||
const noCredential = createHost({ name: 'RDP-NOCRED-01', address: 'rdp-nocred.local', osFamily: 'windows', transport: 'winrm', tags: [], visibility: 'shared' }, owner);
|
||||
assert.throws(() => createRdpLaunchSession(noCredential.id, owner), /assigned visible credential/i);
|
||||
await assert.rejects(() => createRdpLaunchSession(noCredential.id, owner), /assigned visible credential/i);
|
||||
|
||||
const windows = createHost({ name: 'RDP-DISABLED-01', address: 'rdp-disabled.local', osFamily: 'windows', transport: 'winrm', credentialId: credential.id, tags: [], visibility: 'shared' }, owner);
|
||||
updateSettings({ myrtille_enabled: 'false' });
|
||||
await assert.rejects(() => createRdpLaunchSession(windows.id, owner), /Myrtille RDP gateway is not configured/i);
|
||||
});
|
||||
|
||||
test('Myrtille launch URL can carry passwordHash without exposing plaintext', () => {
|
||||
const url = new URL(buildMyrtilleLaunchUrl({
|
||||
gatewayUrl: 'https://rdp.contoso.local/Myrtille/',
|
||||
target: 'win-01.contoso.local',
|
||||
domain: 'CONTOSO',
|
||||
username: 'admin',
|
||||
passwordHash: 'hash-value',
|
||||
width: 1280,
|
||||
height: 800
|
||||
}));
|
||||
assert.equal(url.searchParams.get('passwordHash'), 'hash-value');
|
||||
assert.equal(url.searchParams.has('password'), false);
|
||||
assert.equal(url.searchParams.get('connect'), 'Connect!');
|
||||
});
|
||||
|
||||
test('manual host groups keep explicit members', () => {
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import './setup.mjs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { adminUserId, load } from './setup.mjs';
|
||||
|
||||
const owner = adminUserId();
|
||||
const { createPsadtProfile, renderPsadtProfile } = await load('../models/psadtModel.js');
|
||||
const { createAssetFromUpload, createAssetLink, listLinkedAssets } = await load('../models/assetModel.js');
|
||||
const { buildZip, planPackageLayout } = await load('../services/packageBuilder.js');
|
||||
|
||||
function seedAsset(name, contents) {
|
||||
const tmp = path.join(os.tmpdir(), `pkg-src-${Math.random().toString(16).slice(2)}`);
|
||||
fs.writeFileSync(tmp, contents);
|
||||
return createAssetFromUpload(
|
||||
{ path: tmp, originalname: name, mimetype: 'application/octet-stream', size: contents.length },
|
||||
{ name, visibility: 'shared' },
|
||||
owner
|
||||
);
|
||||
}
|
||||
|
||||
test('listLinkedAssets returns a PSADT profile\'s linked assets with their package path and role', () => {
|
||||
const profile = createPsadtProfile({ name: 'Linked Pkg App', appVendor: 'Acme', appName: 'Tool', visibility: 'shared' }, owner);
|
||||
const installer = seedAsset('setup.exe', Buffer.from('MZ binary'));
|
||||
createAssetLink(installer.id, { targetType: 'psadt_profile', targetId: profile.id, linkRole: 'installer', packagePath: 'Files/setup.exe' }, owner);
|
||||
|
||||
const linked = listLinkedAssets('psadt_profile', profile.id, owner);
|
||||
assert.equal(linked.length, 1);
|
||||
assert.equal(linked[0].assetId, installer.id);
|
||||
assert.equal(linked[0].linkRole, 'installer');
|
||||
assert.equal(linked[0].packagePath, 'Files/setup.exe');
|
||||
});
|
||||
|
||||
test('a profile + linked assets assemble into a zip carrying the rendered script and payload', () => {
|
||||
const profile = createPsadtProfile({ name: 'Assemble App', appVendor: 'Acme', appName: 'Assemble', visibility: 'shared' }, owner);
|
||||
const installer = seedAsset('install.exe', Buffer.from('payload-bytes'));
|
||||
const config = seedAsset('settings.xml', Buffer.from('<config/>'));
|
||||
createAssetLink(installer.id, { targetType: 'psadt_profile', targetId: profile.id, linkRole: 'installer' }, owner);
|
||||
createAssetLink(config.id, { targetType: 'psadt_profile', targetId: profile.id, linkRole: 'support-file' }, owner);
|
||||
|
||||
const rendered = renderPsadtProfile(profile.id, owner);
|
||||
const layout = planPackageLayout(listLinkedAssets('psadt_profile', profile.id, owner));
|
||||
assert.deepEqual(layout.assets.map((a) => a.packagePath).sort(), ['Files/install.exe', 'SupportFiles/settings.xml']);
|
||||
|
||||
const zip = buildZip([
|
||||
{ path: 'Invoke-AppDeployToolkit.ps1', data: Buffer.from(rendered.script, 'utf8') },
|
||||
{ path: 'Files/install.exe', data: Buffer.from('payload-bytes') }
|
||||
]);
|
||||
const names = listZipNames(zip);
|
||||
assert.ok(names.includes('Invoke-AppDeployToolkit.ps1'));
|
||||
assert.ok(names.includes('Files/install.exe'));
|
||||
});
|
||||
|
||||
function listZipNames(buf) {
|
||||
const names = [];
|
||||
let pos = 0;
|
||||
while (buf.readUInt32LE(pos) === 0x04034b50) {
|
||||
const compSize = buf.readUInt32LE(pos + 18);
|
||||
const nameLen = buf.readUInt16LE(pos + 26);
|
||||
const extraLen = buf.readUInt16LE(pos + 28);
|
||||
names.push(buf.slice(pos + 30, pos + 30 + nameLen).toString('utf8'));
|
||||
pos = pos + 30 + nameLen + extraLen + compSize;
|
||||
}
|
||||
return names;
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import zlib from 'node:zlib';
|
||||
import { ENTRY_SCRIPT_NAME, buildZip, planPackageLayout, resolveSetupFile } from '../services/packageBuilder.js';
|
||||
|
||||
// --- layout planning (pure) --------------------------------------------------
|
||||
|
||||
test('planPackageLayout places linked assets by role when no package path is set', () => {
|
||||
const layout = planPackageLayout([
|
||||
{ assetId: 'a1', originalName: 'setup.exe', linkRole: 'installer' },
|
||||
{ assetId: 'a2', originalName: 'config.xml', linkRole: 'support-file' },
|
||||
{ assetId: 'a3', originalName: 'icon.png', linkRole: 'icon' }
|
||||
]);
|
||||
assert.equal(layout.scriptPath, ENTRY_SCRIPT_NAME);
|
||||
assert.deepEqual(layout.assets.map((a) => a.packagePath), ['Files/setup.exe', 'SupportFiles/config.xml', 'Assets/icon.png']);
|
||||
assert.ok(layout.directories.includes('Assets'), 'Assets dir added because an icon is present');
|
||||
});
|
||||
|
||||
test('planPackageLayout honors an explicit package path verbatim', () => {
|
||||
const layout = planPackageLayout([
|
||||
{ assetId: 'a1', originalName: 'setup.exe', linkRole: 'installer', packagePath: 'Files/x64/setup.exe' }
|
||||
]);
|
||||
assert.equal(layout.assets[0].packagePath, 'Files/x64/setup.exe');
|
||||
});
|
||||
|
||||
test('planPackageLayout has no Assets dir when nothing maps there', () => {
|
||||
const layout = planPackageLayout([{ assetId: 'a1', originalName: 'setup.exe', linkRole: 'installer' }]);
|
||||
assert.deepEqual(layout.directories, ['Files', 'SupportFiles']);
|
||||
});
|
||||
|
||||
test('planPackageLayout drops colliding targets with a warning', () => {
|
||||
const layout = planPackageLayout([
|
||||
{ assetId: 'a1', originalName: 'setup.exe', linkRole: 'installer', packagePath: 'Files/setup.exe' },
|
||||
{ assetId: 'a2', originalName: 'other.exe', linkRole: 'installer', packagePath: 'files/SETUP.exe' }
|
||||
]);
|
||||
assert.equal(layout.assets.length, 1, 'second asset on the same path is skipped');
|
||||
assert.equal(layout.warnings.length, 1);
|
||||
});
|
||||
|
||||
test('planPackageLayout relocates a bare entry-script name under its role folder, never overwriting root', () => {
|
||||
const layout = planPackageLayout([
|
||||
{ assetId: 'a1', originalName: 'Invoke-AppDeployToolkit.ps1', linkRole: 'reference', packagePath: 'Invoke-AppDeployToolkit.ps1' }
|
||||
]);
|
||||
assert.equal(layout.assets[0].packagePath, 'SupportFiles/Invoke-AppDeployToolkit.ps1');
|
||||
assert.notEqual(layout.assets[0].packagePath, ENTRY_SCRIPT_NAME);
|
||||
});
|
||||
|
||||
// --- setup file resolution (pure) --------------------------------------------
|
||||
|
||||
test('resolveSetupFile prefers an explicit choice, then installer role, then first Files entry', () => {
|
||||
const layout = planPackageLayout([
|
||||
{ assetId: 'a1', originalName: 'helper.exe', linkRole: 'package-file' },
|
||||
{ assetId: 'a2', originalName: 'setup.exe', linkRole: 'installer' }
|
||||
]);
|
||||
assert.equal(resolveSetupFile(layout, 'Files/manual.exe'), 'Files/manual.exe');
|
||||
assert.equal(resolveSetupFile(layout), 'Files/setup.exe');
|
||||
|
||||
const noInstaller = planPackageLayout([{ assetId: 'a1', originalName: 'helper.exe', linkRole: 'package-file' }]);
|
||||
assert.equal(resolveSetupFile(noInstaller), 'Files/helper.exe');
|
||||
|
||||
const empty = planPackageLayout([]);
|
||||
assert.equal(resolveSetupFile(empty), '');
|
||||
});
|
||||
|
||||
// --- zip writer (pure, round-trips through zlib) -----------------------------
|
||||
|
||||
test('buildZip produces a valid archive whose entries inflate back to their input', () => {
|
||||
const script = 'Write-Output "hello"\n'.repeat(50);
|
||||
const binary = Buffer.from([0, 1, 2, 3, 255, 254, 253]);
|
||||
const zip = buildZip([
|
||||
{ path: ENTRY_SCRIPT_NAME, data: Buffer.from(script, 'utf8') },
|
||||
{ path: 'Files', isDirectory: true },
|
||||
{ path: 'Files/payload.bin', data: binary }
|
||||
]);
|
||||
|
||||
assert.equal(zip.readUInt32LE(0), 0x04034b50, 'starts with a local file header');
|
||||
const recovered = readZip(zip);
|
||||
assert.equal(recovered.get(ENTRY_SCRIPT_NAME).toString('utf8'), script);
|
||||
assert.ok(recovered.has('Files/'), 'directory entry is present');
|
||||
assert.deepEqual([...recovered.get('Files/payload.bin')], [...binary]);
|
||||
});
|
||||
|
||||
test('buildZip stores incompressible/empty data without inflating it', () => {
|
||||
const zip = buildZip([{ path: 'a.txt', data: Buffer.alloc(0) }]);
|
||||
assert.equal(readZip(zip).get('a.txt').length, 0);
|
||||
});
|
||||
|
||||
// Minimal central-directory reader for the test: walk local headers and inflate.
|
||||
function readZip(buf) {
|
||||
const out = new Map();
|
||||
let pos = 0;
|
||||
while (buf.readUInt32LE(pos) === 0x04034b50) {
|
||||
const method = buf.readUInt16LE(pos + 8);
|
||||
const compSize = buf.readUInt32LE(pos + 18);
|
||||
const nameLen = buf.readUInt16LE(pos + 26);
|
||||
const extraLen = buf.readUInt16LE(pos + 28);
|
||||
const name = buf.slice(pos + 30, pos + 30 + nameLen).toString('utf8');
|
||||
const dataStart = pos + 30 + nameLen + extraLen;
|
||||
const stored = buf.slice(dataStart, dataStart + compSize);
|
||||
out.set(name, method === 8 ? zlib.inflateRawSync(stored) : stored);
|
||||
pos = dataStart + compSize;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { buildDatasheetModel, renderDatasheet } from '../services/packageDocService.js';
|
||||
|
||||
function sampleDeployment(overrides = {}) {
|
||||
return {
|
||||
id: 'intune_1',
|
||||
name: 'Acme Reader 24.1',
|
||||
profileId: 'psadt_1',
|
||||
profileName: 'Acme Reader',
|
||||
applicationId: null,
|
||||
appType: 'Windows app (Win32)',
|
||||
commandStyle: 'v4',
|
||||
installBehavior: 'system',
|
||||
restartBehavior: 'return-code',
|
||||
sourceFolder: 'C:\\packages\\reader',
|
||||
intunewinFile: 'reader.intunewin',
|
||||
installCommand: 'Invoke-AppDeployToolkit.exe -DeploymentType Install',
|
||||
uninstallCommand: 'Invoke-AppDeployToolkit.exe -DeploymentType Uninstall',
|
||||
detectionType: 'msi-product-code',
|
||||
detectionRule: '{12345678-ABCD-1234-ABCD-1234567890AB}',
|
||||
requirements: { architecture: 'x64', minOs: 'Windows 10 22H2', diskSpaceMb: 250, runAs32Bit: false },
|
||||
returnCodes: [
|
||||
{ code: 0, type: 'success', meaning: 'Install completed.' },
|
||||
{ code: 3010, type: 'softReboot', meaning: 'Reboot required.' }
|
||||
],
|
||||
assignments: [
|
||||
{ ring: 'Pilot', intent: 'available', groupName: 'IT-Pilot', notes: 'Validation ring.' }
|
||||
],
|
||||
status: 'draft',
|
||||
notes: 'Created from the recipe.',
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
const sampleProfile = {
|
||||
name: 'Acme Reader',
|
||||
appVendor: 'Acme',
|
||||
appName: 'Reader',
|
||||
appVersion: '24.1',
|
||||
appArch: 'x64'
|
||||
};
|
||||
|
||||
test('buildDatasheetModel pulls metadata from the profile and lays out every section', () => {
|
||||
const model = buildDatasheetModel({ deployment: sampleDeployment(), profile: sampleProfile });
|
||||
assert.equal(model.title, 'Acme Reader 24.1');
|
||||
assert.equal(model.subtitle, 'Acme Reader 24.1');
|
||||
const headings = model.sections.map((s) => s.heading);
|
||||
assert.deepEqual(headings, ['Overview', 'Install & uninstall', 'Detection', 'Return codes', 'Assignments', 'Requirements', 'Source & version', 'Notes']);
|
||||
});
|
||||
|
||||
test('buildDatasheetModel requires a deployment', () => {
|
||||
assert.throws(() => buildDatasheetModel({}), /deployment is required/);
|
||||
});
|
||||
|
||||
test('buildDatasheetModel omits the Notes section when there are no notes', () => {
|
||||
const model = buildDatasheetModel({ deployment: sampleDeployment({ notes: '' }), profile: sampleProfile });
|
||||
assert.ok(!model.sections.some((s) => s.heading === 'Notes'));
|
||||
});
|
||||
|
||||
test('renderDatasheet markdown carries commands, detection, return codes and assignments', () => {
|
||||
const doc = renderDatasheet({ deployment: sampleDeployment(), profile: sampleProfile }, { format: 'md' });
|
||||
assert.equal(doc.format, 'md');
|
||||
assert.equal(doc.extension, 'md');
|
||||
assert.match(doc.contentType, /text\/markdown/);
|
||||
assert.match(doc.content, /^# Acme Reader 24\.1/);
|
||||
assert.match(doc.content, /Invoke-AppDeployToolkit\.exe -DeploymentType Install/);
|
||||
assert.match(doc.content, /\{12345678-ABCD-1234-ABCD-1234567890AB\}/);
|
||||
assert.match(doc.content, /\| 3010 \| Soft reboot \| Reboot required\. \|/);
|
||||
assert.match(doc.content, /\| Pilot \| Available \| IT-Pilot \| Validation ring\. \|/);
|
||||
});
|
||||
|
||||
test('renderDatasheet html is self-contained and escapes user content', () => {
|
||||
const doc = renderDatasheet(
|
||||
{ deployment: sampleDeployment({ notes: 'Watch <script> & "quotes"' }), profile: sampleProfile },
|
||||
{ format: 'html' }
|
||||
);
|
||||
assert.equal(doc.format, 'html');
|
||||
assert.equal(doc.extension, 'html');
|
||||
assert.match(doc.contentType, /text\/html/);
|
||||
assert.match(doc.content, /^<!doctype html>/);
|
||||
assert.match(doc.content, /Watch <script> & "quotes"/);
|
||||
assert.ok(!doc.content.includes('<script>'), 'no unescaped script tag leaks through');
|
||||
});
|
||||
|
||||
test('renderDatasheet shows empty tables gracefully and defaults to markdown', () => {
|
||||
const doc = renderDatasheet({ deployment: sampleDeployment({ assignments: [], returnCodes: [] }) });
|
||||
assert.equal(doc.format, 'md');
|
||||
assert.match(doc.content, /_None_/);
|
||||
});
|
||||
|
||||
test('renderDatasheet falls back to the deployment name without a profile', () => {
|
||||
const doc = renderDatasheet({ deployment: sampleDeployment({ profileId: null }) }, { format: 'md' });
|
||||
assert.match(doc.content, /# Acme Reader 24\.1/);
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import './setup.mjs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { load } from './setup.mjs';
|
||||
|
||||
const { listRecipes, searchRecipes, getRecipe, buildRecipeArtifacts } =
|
||||
await load('../services/recipeService.js');
|
||||
|
||||
// --- catalog -----------------------------------------------------------------
|
||||
|
||||
test('listRecipes exposes a sanitized catalog with the fields the UI needs', () => {
|
||||
const list = listRecipes();
|
||||
assert.ok(list.length >= 10, 'catalog has a useful number of recipes');
|
||||
const chrome = list.find((r) => r.id === 'google-chrome');
|
||||
assert.ok(chrome);
|
||||
assert.equal(chrome.wingetId, 'Google.Chrome');
|
||||
assert.ok(chrome.installArgs.includes('/qn'));
|
||||
assert.ok(chrome.homepage.startsWith('https://'));
|
||||
});
|
||||
|
||||
test('every recipe references a known installer technology and a valid detection footprint', async () => {
|
||||
const { listInstallerTechnologies } = await load('../services/installerIntelService.js');
|
||||
const techIds = new Set(listInstallerTechnologies().map((t) => t.id));
|
||||
for (const r of listRecipes()) {
|
||||
assert.ok(techIds.has(r.installerType), `${r.id} -> ${r.installerType} is a known technology`);
|
||||
// Detection must not throw when applied (file/registry/msi all valid here).
|
||||
assert.doesNotThrow(() => buildRecipeArtifacts(getRecipe(r.id)), `${r.id} detection builds`);
|
||||
}
|
||||
});
|
||||
|
||||
test('enterprise agents without a winget id still build, but skip the catalog app', () => {
|
||||
const falcon = getRecipe('crowdstrike-falcon');
|
||||
assert.equal(falcon.wingetId, '');
|
||||
const { application, deployment } = buildRecipeArtifacts(falcon);
|
||||
assert.equal(application, null, 'no winget id -> no auto-tracked app');
|
||||
assert.ok(deployment.detectionRule.includes('CSFalconService.exe'));
|
||||
});
|
||||
|
||||
test('searchRecipes matches vendor, name, and winget id case-insensitively', () => {
|
||||
assert.ok(searchRecipes('mozilla').some((r) => r.id === 'mozilla-firefox'));
|
||||
assert.ok(searchRecipes('VLC').some((r) => r.id === 'vlc'));
|
||||
assert.ok(searchRecipes('Microsoft.PowerShell').some((r) => r.id === 'powershell-7'));
|
||||
assert.equal(searchRecipes('nonexistent-app-xyz').length, 0);
|
||||
assert.equal(searchRecipes('').length, listRecipes().length);
|
||||
});
|
||||
|
||||
// --- apply transform (pure) --------------------------------------------------
|
||||
|
||||
test('buildRecipeArtifacts produces profile + deployment + tracked app for an MSI recipe', () => {
|
||||
const { profile, deployment, application } = buildRecipeArtifacts(getRecipe('google-chrome'));
|
||||
|
||||
// Profile carries the raw silent install as a PSADT install task.
|
||||
assert.equal(profile.name, 'Google Chrome');
|
||||
assert.equal(profile.appVendor, 'Google');
|
||||
assert.equal(profile.installTasks.length, 1);
|
||||
assert.equal(profile.installTasks[0].type, 'msi');
|
||||
assert.equal(profile.installTasks[0].filePath, 'googlechromestandaloneenterprise64.msi');
|
||||
assert.ok(profile.installTasks[0].arguments.includes('/qn'));
|
||||
|
||||
// Deployment wraps it in PSADT v4 with a file-detection rule.
|
||||
assert.equal(deployment.commandStyle, 'v4');
|
||||
assert.equal(deployment.detectionType, 'custom-script');
|
||||
assert.ok(deployment.detectionRule.includes('chrome.exe'));
|
||||
assert.equal(deployment.status, 'draft');
|
||||
|
||||
// Catalog app is wired to the winget version watcher.
|
||||
assert.equal(application.versionSource, 'winget');
|
||||
assert.equal(application.versionSourceRef, 'Google.Chrome');
|
||||
assert.equal(application.autoCheck, true);
|
||||
});
|
||||
|
||||
test('EXE-based recipes become exe install tasks with the silent switch', () => {
|
||||
const { profile } = buildRecipeArtifacts(getRecipe('git'));
|
||||
assert.equal(profile.installTasks[0].type, 'exe');
|
||||
assert.ok(profile.installTasks[0].arguments.includes('/VERYSILENT'));
|
||||
});
|
||||
|
||||
test('applying with group visibility scopes the group id', () => {
|
||||
const { profile, deployment } = buildRecipeArtifacts(getRecipe('7zip'), { visibility: 'group', groupId: 'grp_1' });
|
||||
assert.equal(profile.visibility, 'group');
|
||||
assert.equal(profile.groupId, 'grp_1');
|
||||
assert.equal(deployment.groupId, 'grp_1');
|
||||
});
|
||||
|
||||
test('group id is dropped when visibility is not group', () => {
|
||||
const { profile } = buildRecipeArtifacts(getRecipe('7zip'), { visibility: 'personal', groupId: 'grp_1' });
|
||||
assert.equal(profile.groupId, null);
|
||||
});
|
||||
|
||||
test('buildRecipeArtifacts throws on a missing recipe', () => {
|
||||
assert.throws(() => buildRecipeArtifacts(null));
|
||||
});
|
||||
Reference in new issue
Block a user