163 lines
7.7 KiB
JavaScript
163 lines
7.7 KiB
JavaScript
import { adminUserId, load } from './setup.mjs';
|
|
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
|
|
const {
|
|
buildWin32LobAppPayload,
|
|
buildAssignmentsPayload,
|
|
buildRelationshipsPayload,
|
|
validateRelationships,
|
|
MAX_SUPERSEDENCE_TARGETS
|
|
} = await load('../services/intunePublishService.js');
|
|
const { createGraphConnection, recordGraphAudit, listGraphAudit } = await load('../models/graphModel.js');
|
|
|
|
const owner = adminUserId();
|
|
|
|
function deploymentFixture(overrides = {}) {
|
|
return {
|
|
id: 'intune_fixture',
|
|
name: 'Contoso VPN',
|
|
notes: 'Pilot ring',
|
|
intunewinFile: 'ContosoVpn.intunewin',
|
|
installBehavior: 'system',
|
|
restartBehavior: 'return-code',
|
|
installCommand: 'Invoke-AppDeployToolkit.exe -DeploymentType Install',
|
|
uninstallCommand: 'Invoke-AppDeployToolkit.exe -DeploymentType Uninstall',
|
|
detectionType: 'msi-product-code',
|
|
detectionRule: '{12345678-1234-1234-1234-123456789012}',
|
|
returnCodes: [{ code: 3010, type: 'softReboot' }, { code: 1602, type: 'retry' }],
|
|
requirements: { architecture: 'both', minOs: 'Windows 10 22H2', diskSpaceMb: 512, runAs32Bit: false },
|
|
...overrides
|
|
};
|
|
}
|
|
|
|
const profileFixture = { appVendor: 'Contoso', appName: 'VPN Client', appVersion: '3.1.0' };
|
|
|
|
test('builds a win32LobApp payload with correct core shape', () => {
|
|
const { payload } = buildWin32LobAppPayload(deploymentFixture(), profileFixture);
|
|
assert.equal(payload['@odata.type'], '#microsoft.graph.win32LobApp');
|
|
assert.equal(payload.displayName, 'Contoso VPN Client 3.1.0');
|
|
assert.equal(payload.publisher, 'Contoso');
|
|
assert.equal(payload.installExperience.runAsAccount, 'system');
|
|
assert.equal(payload.installExperience.deviceRestartBehavior, 'basedOnReturnCode');
|
|
assert.equal(payload.applicableArchitectures, 'x64,x86');
|
|
assert.equal(payload.minimumSupportedOperatingSystem.v10_22H2, true);
|
|
});
|
|
|
|
test('return codes always include 0=success and clamp unknown types', () => {
|
|
const { payload } = buildWin32LobAppPayload(deploymentFixture({ returnCodes: [{ code: 7, type: 'bogus' }] }));
|
|
assert.ok(payload.returnCodes.some((rc) => rc.returnCode === 0 && rc.type === 'success'));
|
|
assert.equal(payload.returnCodes.find((rc) => rc.returnCode === 7).type, 'failed');
|
|
});
|
|
|
|
test('MSI detection maps to product code rule', () => {
|
|
const { payload } = buildWin32LobAppPayload(deploymentFixture());
|
|
const rule = payload.detectionRules[0];
|
|
assert.equal(rule['@odata.type'], '#microsoft.graph.win32LobAppProductCodeDetection');
|
|
assert.equal(rule.productCode, '{12345678-1234-1234-1234-123456789012}');
|
|
});
|
|
|
|
test('custom-script detection base64-encodes the script body', () => {
|
|
const { payload } = buildWin32LobAppPayload(deploymentFixture({ detectionType: 'custom-script', detectionRule: 'Test-Path C:\\app.exe' }));
|
|
const rule = payload.detectionRules[0];
|
|
assert.equal(rule['@odata.type'], '#microsoft.graph.win32LobAppPowerShellScriptDetection');
|
|
assert.equal(Buffer.from(rule.scriptContent, 'base64').toString('utf8'), 'Test-Path C:\\app.exe');
|
|
});
|
|
|
|
test('missing .intunewin produces a content warning', () => {
|
|
const { warnings } = buildWin32LobAppPayload(deploymentFixture({ intunewinFile: '' }));
|
|
assert.ok(warnings.some((w) => w.toLowerCase().includes('content must be uploaded')));
|
|
});
|
|
|
|
// --- Phase 3: assignments -------------------------------------------------
|
|
|
|
test('group assignment maps to groupAssignmentTarget with intent', () => {
|
|
const { assignments } = buildAssignmentsPayload({
|
|
assignments: [{ ring: 'Broad', intent: 'required', groupMode: 'group', groupId: 'g-1' }]
|
|
});
|
|
assert.equal(assignments.length, 1);
|
|
assert.equal(assignments[0].intent, 'required');
|
|
assert.equal(assignments[0].target['@odata.type'], '#microsoft.graph.groupAssignmentTarget');
|
|
assert.equal(assignments[0].target.groupId, 'g-1');
|
|
});
|
|
|
|
test('exclude intent maps to exclusionGroupAssignmentTarget', () => {
|
|
const { assignments } = buildAssignmentsPayload({
|
|
assignments: [{ ring: 'Exclusions', intent: 'exclude', groupMode: 'group', groupId: 'g-x' }]
|
|
});
|
|
assert.equal(assignments[0].target['@odata.type'], '#microsoft.graph.exclusionGroupAssignmentTarget');
|
|
assert.ok(['required', 'available', 'uninstall'].includes(assignments[0].intent));
|
|
});
|
|
|
|
test('all-devices target and assignment filters are honored', () => {
|
|
const { assignments } = buildAssignmentsPayload({
|
|
assignments: [{ ring: 'AllDev', intent: 'available', groupMode: 'allDevices', filterId: 'f-1', filterType: 'exclude', autoUpdate: true }]
|
|
});
|
|
assert.equal(assignments[0].target['@odata.type'], '#microsoft.graph.allDevicesAssignmentTarget');
|
|
assert.equal(assignments[0].target.deviceAndAppManagementAssignmentFilterId, 'f-1');
|
|
assert.equal(assignments[0].target.deviceAndAppManagementAssignmentFilterType, 'exclude');
|
|
assert.equal(assignments[0].settings.autoUpdateSettings.autoUpdateSupersededApps, 'enabled');
|
|
});
|
|
|
|
test('group assignment without an object id is skipped with a warning', () => {
|
|
const { assignments, warnings } = buildAssignmentsPayload({
|
|
assignments: [{ ring: 'Broad', intent: 'required', groupMode: 'group', groupId: '' }]
|
|
});
|
|
assert.equal(assignments.length, 0);
|
|
assert.ok(warnings.some((w) => w.includes('Broad')));
|
|
});
|
|
|
|
// --- Phase 4: relationships ----------------------------------------------
|
|
|
|
test('supersedence and dependency relationships map to correct Graph types', () => {
|
|
const { relationships } = buildRelationshipsPayload([
|
|
{ kind: 'supersedence', targetAppId: 'old-app', mode: 'replace' },
|
|
{ kind: 'dependency', targetAppId: 'dep-app', mode: 'autoInstall' }
|
|
], 'self-app');
|
|
assert.equal(relationships[0]['@odata.type'], '#microsoft.graph.mobileAppSupersedence');
|
|
assert.equal(relationships[0].supersedenceType, 'replace');
|
|
assert.equal(relationships[1]['@odata.type'], '#microsoft.graph.mobileAppDependency');
|
|
assert.equal(relationships[1].dependencyType, 'autoInstall');
|
|
});
|
|
|
|
test('relationship validation rejects self-reference and duplicates', () => {
|
|
const errors = validateRelationships([
|
|
{ kind: 'supersedence', targetAppId: 'self' },
|
|
{ kind: 'supersedence', targetAppId: 'a' },
|
|
{ kind: 'supersedence', targetAppId: 'a' }
|
|
], 'self');
|
|
assert.ok(errors.some((e) => e.includes('itself')));
|
|
assert.ok(errors.some((e) => e.toLowerCase().includes('duplicate')));
|
|
});
|
|
|
|
test('relationship validation enforces the supersedence node ceiling', () => {
|
|
const many = Array.from({ length: MAX_SUPERSEDENCE_TARGETS + 1 }, (_, i) => ({ kind: 'supersedence', targetAppId: `app-${i}` }));
|
|
const errors = validateRelationships(many, 'self');
|
|
assert.ok(errors.some((e) => e.includes('Too many supersedence')));
|
|
assert.throws(() => buildRelationshipsPayload(many, 'self'));
|
|
});
|
|
|
|
test('graph connection persists the allow_write write-gate', () => {
|
|
const conn = createGraphConnection({
|
|
name: 'WriteConn', tenantId: 'contoso', clientId: 'abc', clientSecret: 'shh',
|
|
allowWrite: true, visibility: 'personal'
|
|
}, owner);
|
|
assert.equal(conn.allowWrite, true);
|
|
});
|
|
|
|
test('audit log records and reads back request/response payloads', () => {
|
|
// connection/deployment are nullable FKs; use null here and assert the
|
|
// payload round-trips. (Real publishes pass real ids.)
|
|
recordGraphAudit({
|
|
connectionId: null, deploymentId: null, actorUserId: owner, actorEmail: 'a@b.c',
|
|
action: 'win32app.create', targetGraphId: 'app123', status: 'success', message: 'ok',
|
|
request: { displayName: 'X' }, response: { id: 'app123' }
|
|
});
|
|
const rows = listGraphAudit({ limit: 5 });
|
|
const entry = rows.find((r) => r.targetGraphId === 'app123');
|
|
assert.ok(entry, 'audit entry should be retrievable');
|
|
assert.equal(entry.action, 'win32app.create');
|
|
assert.equal(entry.request.displayName, 'X');
|
|
assert.equal(entry.response.id, 'app123');
|
|
});
|