Initial
This commit is contained in:
78
server/test/applications.test.mjs
Normal file
78
server/test/applications.test.mjs
Normal file
@@ -0,0 +1,78 @@
|
||||
import { adminUserId, makeUser, load } from './setup.mjs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
const { compareVersions, evaluateUpdateState, mapGraphAppToCatalog } = await load('../services/appVersionService.js');
|
||||
const { createApplication, listApplications, loadApplication, updateApplication, deleteApplication, recordVersionCheck } = await load('../models/applicationModel.js');
|
||||
const { runCatalogChecks } = await load('../services/catalogWorker.js');
|
||||
|
||||
const owner = adminUserId();
|
||||
const other = makeUser({ email: 'app-other@test.local' });
|
||||
|
||||
// --- pure version logic ---------------------------------------------------
|
||||
|
||||
test('compareVersions orders numeric segments correctly', () => {
|
||||
assert.equal(compareVersions('1.2.0', '1.10.0'), -1);
|
||||
assert.equal(compareVersions('2.0', '1.9.9'), 1);
|
||||
assert.equal(compareVersions('1.0.0', '1.0.0'), 0);
|
||||
assert.equal(compareVersions('v3.1', '3.1.0'), 0);
|
||||
});
|
||||
|
||||
test('evaluateUpdateState flags newer upstream versions', () => {
|
||||
assert.equal(evaluateUpdateState({ currentVersion: '1.0.0', latestKnownVersion: '1.1.0' }).updateAvailable, true);
|
||||
assert.equal(evaluateUpdateState({ currentVersion: '2.0.0', latestKnownVersion: '1.1.0' }).updateAvailable, false);
|
||||
assert.equal(evaluateUpdateState({ currentVersion: '', latestKnownVersion: '1.0.0' }).updateAvailable, true);
|
||||
assert.equal(evaluateUpdateState({ currentVersion: '1.0.0', latestKnownVersion: '' }).updateAvailable, false);
|
||||
});
|
||||
|
||||
test('mapGraphAppToCatalog maps the key fields', () => {
|
||||
const mapped = mapGraphAppToCatalog({ id: 'app-1', displayName: 'Contoso VPN', publisher: 'Contoso', displayVersion: '3.1.0' });
|
||||
assert.equal(mapped.name, 'Contoso VPN');
|
||||
assert.equal(mapped.vendor, 'Contoso');
|
||||
assert.equal(mapped.currentVersion, '3.1.0');
|
||||
assert.equal(mapped.currentGraphAppId, 'app-1');
|
||||
});
|
||||
|
||||
// --- catalog model --------------------------------------------------------
|
||||
|
||||
test('applications are visibility-scoped', () => {
|
||||
const app = createApplication({ name: 'Private App', currentVersion: '1.0.0', visibility: 'personal' }, owner);
|
||||
assert.ok(listApplications(owner).some((a) => a.id === app.id));
|
||||
assert.ok(!listApplications(other).some((a) => a.id === app.id));
|
||||
assert.equal(loadApplication(app.id, other), null);
|
||||
});
|
||||
|
||||
test('application read surfaces a computed updateState', () => {
|
||||
const app = createApplication({ name: 'Edge', currentVersion: '1.0.0', latestKnownVersion: '1.2.0', visibility: 'shared' }, owner);
|
||||
const loaded = loadApplication(app.id, owner);
|
||||
assert.equal(loaded.updateState.updateAvailable, true);
|
||||
assert.equal(loaded.updateState.latest, '1.2.0');
|
||||
});
|
||||
|
||||
test('recordVersionCheck updates the latest known version', () => {
|
||||
const app = createApplication({ name: 'Firefox', currentVersion: '120.0', visibility: 'shared' }, owner);
|
||||
const updated = recordVersionCheck(app.id, owner, { latestKnownVersion: '121.0', message: '' });
|
||||
assert.equal(updated.latestKnownVersion, '121.0');
|
||||
assert.equal(updated.updateState.updateAvailable, true);
|
||||
assert.ok(updated.lastCheckedAt);
|
||||
});
|
||||
|
||||
test('auto-check worker only processes opted-in apps and records new versions', async () => {
|
||||
const optedIn = createApplication({ name: 'Worker Opt-In', currentVersion: '1.0.0', latestKnownVersion: '1.0.0', versionSource: 'url', versionSourceRef: 'https://example/v', autoCheck: true, visibility: 'shared' }, owner);
|
||||
const optedOut = createApplication({ name: 'Worker Opt-Out', currentVersion: '1.0.0', latestKnownVersion: '1.0.0', autoCheck: false, visibility: 'shared' }, owner);
|
||||
|
||||
// Injected fetcher avoids network; returns a newer version.
|
||||
const summary = await runCatalogChecks({ fetcher: async () => ({ version: '2.0.0', message: '' }) });
|
||||
|
||||
assert.ok(summary.checked >= 1);
|
||||
assert.ok(summary.updated >= 1);
|
||||
assert.equal(loadApplication(optedIn.id, owner).latestKnownVersion, '2.0.0');
|
||||
assert.equal(loadApplication(optedOut.id, owner).latestKnownVersion, '1.0.0');
|
||||
});
|
||||
|
||||
test('a non-owner cannot update or delete a personal application', () => {
|
||||
const app = createApplication({ name: 'Locked App', visibility: 'personal' }, owner);
|
||||
assert.equal(updateApplication(app.id, { name: 'Hijacked' }, other), null);
|
||||
deleteApplication(app.id, other);
|
||||
assert.ok(loadApplication(app.id, owner), 'should survive foreign delete');
|
||||
});
|
||||
54
server/test/auto-promote.test.mjs
Normal file
54
server/test/auto-promote.test.mjs
Normal file
@@ -0,0 +1,54 @@
|
||||
import { db, adminUserId, load } from './setup.mjs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
const { evaluatePromotion, runAutoPromotions } = await load('../services/promotionWorker.js');
|
||||
const { createIntuneDeployment, loadDeploymentRaw } = await load('../models/psadtModel.js');
|
||||
|
||||
const owner = adminUserId();
|
||||
const hoursAgo = (h) => new Date(Date.now() - h * 3_600_000).toISOString();
|
||||
|
||||
// --- pure eligibility -----------------------------------------------------
|
||||
|
||||
test('evaluatePromotion is due once the soak window elapses', () => {
|
||||
const d = { autoPromoteAfterHours: 1, autoPromoteRing: 'Broad', soakStartedAt: hoursAgo(2), promotedAt: '' };
|
||||
assert.equal(evaluatePromotion(d).due, true);
|
||||
});
|
||||
|
||||
test('evaluatePromotion is not due while soaking, disabled, or already promoted', () => {
|
||||
assert.equal(evaluatePromotion({ autoPromoteAfterHours: 4, autoPromoteRing: 'Broad', soakStartedAt: hoursAgo(1), promotedAt: '' }).due, false);
|
||||
assert.equal(evaluatePromotion({ autoPromoteAfterHours: 0, autoPromoteRing: 'Broad', soakStartedAt: hoursAgo(5) }).due, false);
|
||||
assert.equal(evaluatePromotion({ autoPromoteAfterHours: 1, autoPromoteRing: 'Broad', soakStartedAt: hoursAgo(5), promotedAt: hoursAgo(1) }).due, false);
|
||||
assert.equal(evaluatePromotion({ autoPromoteAfterHours: 1, autoPromoteRing: '', soakStartedAt: hoursAgo(5) }).due, false);
|
||||
});
|
||||
|
||||
// --- worker run -----------------------------------------------------------
|
||||
|
||||
test('runAutoPromotions promotes an eligible deployment and skips a soaking one', async () => {
|
||||
const eligible = createIntuneDeployment({
|
||||
name: 'Promote Me', visibility: 'shared', autoPromoteAfterHours: 1, autoPromoteRing: 'Broad', autoPromoteIntent: 'required',
|
||||
assignments: [{ ring: 'Broad', intent: 'available', groupId: 'g1', groupMode: 'group' }]
|
||||
}, owner);
|
||||
const soaking = createIntuneDeployment({
|
||||
name: 'Still Soaking', visibility: 'shared', autoPromoteAfterHours: 8, autoPromoteRing: 'Broad', autoPromoteIntent: 'required',
|
||||
assignments: [{ ring: 'Broad', intent: 'available', groupId: 'g2', groupMode: 'group' }]
|
||||
}, owner);
|
||||
|
||||
// Start the soak clock: eligible elapsed, soaking just started.
|
||||
db.prepare('UPDATE intune_deployments SET soak_started_at = ? WHERE id = ?').run(hoursAgo(3), eligible.id);
|
||||
db.prepare('UPDATE intune_deployments SET soak_started_at = ? WHERE id = ?').run(hoursAgo(1), soaking.id);
|
||||
|
||||
let pushed = 0;
|
||||
const summary = await runAutoPromotions({ pusher: async () => { pushed += 1; } });
|
||||
|
||||
assert.ok(summary.promoted >= 1);
|
||||
const promoted = loadDeploymentRaw(eligible.id);
|
||||
assert.equal(promoted.assignments.find((a) => a.ring === 'Broad').intent, 'required');
|
||||
assert.ok(promoted.promotedAt);
|
||||
|
||||
const stillSoaking = loadDeploymentRaw(soaking.id);
|
||||
assert.equal(stillSoaking.assignments.find((a) => a.ring === 'Broad').intent, 'available');
|
||||
assert.equal(stillSoaking.promotedAt, '');
|
||||
// Neither deployment has a Graph connection, so nothing was pushed.
|
||||
assert.equal(pushed, 0);
|
||||
});
|
||||
38
server/test/builder.test.mjs
Normal file
38
server/test/builder.test.mjs
Normal file
@@ -0,0 +1,38 @@
|
||||
import './setup.mjs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { load } from './setup.mjs';
|
||||
|
||||
const { buildPackagerInvocation, expectedOutputFile, resolveToolPath, builderAvailability } = await load('../services/intuneWinBuilder.js');
|
||||
|
||||
test('bundled invocation uses IntuneWinAppUtil flags', () => {
|
||||
const inv = buildPackagerInvocation({ toolPath: 'C:/tools/IntuneWinAppUtil.exe', sourceFolder: 'C:/src', setupFile: 'setup.exe', outputDir: 'C:/out' });
|
||||
assert.equal(inv.shell, false);
|
||||
assert.equal(inv.command, 'C:/tools/IntuneWinAppUtil.exe');
|
||||
assert.deepEqual(inv.args, ['-c', 'C:/src', '-s', 'setup.exe', '-o', 'C:/out', '-q']);
|
||||
});
|
||||
|
||||
test('external command template substitutes placeholders', () => {
|
||||
const inv = buildPackagerInvocation({
|
||||
commandTemplate: 'pack --src {source} --setup {setup} --out {output}',
|
||||
sourceFolder: '/s', setupFile: 'a.msi', outputDir: '/o'
|
||||
});
|
||||
assert.equal(inv.shell, true);
|
||||
assert.equal(inv.command, 'pack --src /s --setup a.msi --out /o');
|
||||
});
|
||||
|
||||
test('expectedOutputFile names after the setup base name', () => {
|
||||
assert.ok(expectedOutputFile('/out', 'ContosoVpnSetup.exe').endsWith('ContosoVpnSetup.intunewin'));
|
||||
assert.ok(expectedOutputFile('/out', 'app.msi').endsWith('app.intunewin'));
|
||||
});
|
||||
|
||||
test('resolveToolPath defaults into the tools directory', () => {
|
||||
assert.ok(resolveToolPath().toLowerCase().includes('intunewinapputil.exe'));
|
||||
});
|
||||
|
||||
test('builderAvailability reports unavailable when the tool is missing on this host', () => {
|
||||
// The test host has no tool placed and is not Windows, so build is unavailable.
|
||||
const availability = builderAvailability();
|
||||
assert.equal(availability.available, false);
|
||||
assert.ok(availability.reason);
|
||||
});
|
||||
62
server/test/drift-rbac.test.mjs
Normal file
62
server/test/drift-rbac.test.mjs
Normal file
@@ -0,0 +1,62 @@
|
||||
import './setup.mjs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { load } from './setup.mjs';
|
||||
|
||||
const { computeDrift } = await load('../services/intuneDriftService.js');
|
||||
const { canPublish, canApprove } = await load('../services/graphRbac.js');
|
||||
|
||||
// --- drift ----------------------------------------------------------------
|
||||
|
||||
test('computeDrift reports in-sync when plan matches tenant', () => {
|
||||
const app = { displayName: 'Edge', publisher: 'Microsoft', installCommandLine: 'a', uninstallCommandLine: 'b', applicableArchitectures: 'x64' };
|
||||
const assignment = { intent: 'required', target: { '@odata.type': '#microsoft.graph.groupAssignmentTarget', groupId: 'g1' } };
|
||||
const rel = { '@odata.type': '#microsoft.graph.mobileAppSupersedence', targetId: 'old' };
|
||||
const drift = computeDrift({
|
||||
expectedApp: app, liveApp: { ...app },
|
||||
expectedAssignments: [assignment], liveAssignments: [assignment],
|
||||
expectedRelationships: [rel], liveRelationships: [rel]
|
||||
});
|
||||
assert.equal(drift.inSync, true);
|
||||
});
|
||||
|
||||
test('computeDrift detects field, assignment, and relationship differences', () => {
|
||||
const drift = computeDrift({
|
||||
expectedApp: { displayName: 'Edge', installCommandLine: 'new' },
|
||||
liveApp: { displayName: 'Edge', installCommandLine: 'old' },
|
||||
expectedAssignments: [{ intent: 'required', target: { '@odata.type': '#microsoft.graph.groupAssignmentTarget', groupId: 'g1' } }],
|
||||
liveAssignments: [{ intent: 'available', target: { '@odata.type': '#microsoft.graph.groupAssignmentTarget', groupId: 'g1' } }],
|
||||
expectedRelationships: [{ '@odata.type': '#microsoft.graph.mobileAppSupersedence', targetId: 'old' }],
|
||||
liveRelationships: []
|
||||
});
|
||||
assert.equal(drift.inSync, false);
|
||||
assert.ok(drift.appDrift.some((d) => d.field === 'installCommandLine'));
|
||||
assert.equal(drift.assignmentDrift.missing.length, 1); // required|...|g1 missing in tenant
|
||||
assert.equal(drift.assignmentDrift.extra.length, 1); // available|...|g1 extra in tenant
|
||||
assert.equal(drift.relationshipDrift.missing.length, 1);
|
||||
});
|
||||
|
||||
// --- per-connection RBAC --------------------------------------------------
|
||||
|
||||
const admin = { id: 'u-admin', role: 'admin' };
|
||||
const alice = { id: 'u-alice', role: 'user' };
|
||||
const bob = { id: 'u-bob', role: 'user' };
|
||||
|
||||
test('canPublish: admins always; empty list allows all; otherwise listed only', () => {
|
||||
assert.equal(canPublish({ publisherIds: [] }, admin), true);
|
||||
assert.equal(canPublish({ publisherIds: [] }, alice), true);
|
||||
assert.equal(canPublish({ publisherIds: ['u-alice'] }, alice), true);
|
||||
assert.equal(canPublish({ publisherIds: ['u-alice'] }, bob), false);
|
||||
});
|
||||
|
||||
test('canApprove: admins always; otherwise only named approvers (empty = none)', () => {
|
||||
assert.equal(canApprove({ approverIds: [] }, admin), true);
|
||||
assert.equal(canApprove({ approverIds: [] }, alice), false);
|
||||
assert.equal(canApprove({ approverIds: ['u-alice'] }, alice), true);
|
||||
assert.equal(canApprove({ approverIds: ['u-alice'] }, bob), false);
|
||||
});
|
||||
|
||||
test('RBAC helpers tolerate JSON-string list columns', () => {
|
||||
assert.equal(canPublish({ publisher_ids: '["u-alice"]' }, alice), true);
|
||||
assert.equal(canApprove({ approver_ids: '["u-bob"]' }, bob), true);
|
||||
});
|
||||
19
server/test/entra.test.mjs
Normal file
19
server/test/entra.test.mjs
Normal file
@@ -0,0 +1,19 @@
|
||||
import './setup.mjs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { load } from './setup.mjs';
|
||||
|
||||
const { isEntraLoginConfigured, signState, verifyState } = await load('../services/entraService.js');
|
||||
|
||||
test('Entra login is disabled when not configured', () => {
|
||||
// The test environment leaves ENTRA_* unset, so login must not be offered.
|
||||
assert.equal(isEntraLoginConfigured(), false);
|
||||
});
|
||||
|
||||
test('signed state round-trips and rejects tampering', () => {
|
||||
const state = signState({ nonce: 42 });
|
||||
const decoded = verifyState(state);
|
||||
assert.equal(decoded.purpose, 'entra-state');
|
||||
assert.throws(() => verifyState(state + 'tampered'));
|
||||
assert.throws(() => verifyState('not-a-token'));
|
||||
});
|
||||
71
server/test/governance.test.mjs
Normal file
71
server/test/governance.test.mjs
Normal file
@@ -0,0 +1,71 @@
|
||||
import { db, adminUserId, makeUser, load } from './setup.mjs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
const {
|
||||
createChangeRequest, findApprovedRequest, findPendingRequest, decideChangeRequest, markRequestExecuted, listChangeRequests
|
||||
} = await load('../models/changeRequestModel.js');
|
||||
const { intuneReportingOverview, recordGraphAudit } = await load('../models/graphModel.js');
|
||||
const { createIntuneDeployment } = await load('../models/psadtModel.js');
|
||||
|
||||
const owner = adminUserId();
|
||||
const other = makeUser({ email: 'gov-other@test.local' });
|
||||
|
||||
const deployment = createIntuneDeployment({ name: 'Gov Test App', visibility: 'shared', status: 'ready' }, owner);
|
||||
|
||||
test('approval lifecycle: pending -> approved -> executed', () => {
|
||||
const request = createChangeRequest({ deploymentId: deployment.id, connectionId: null, action: 'publish', requestedBy: owner, requesterEmail: 'a@b.c' });
|
||||
assert.equal(request.status, 'pending');
|
||||
assert.equal(findPendingRequest(deployment.id, 'publish').id, request.id);
|
||||
assert.equal(findApprovedRequest(deployment.id, 'publish'), null);
|
||||
|
||||
const approved = decideChangeRequest(request.id, { status: 'approved', approverUserId: owner, approverEmail: 'admin@b.c' });
|
||||
assert.equal(approved.status, 'approved');
|
||||
assert.equal(findApprovedRequest(deployment.id, 'publish').id, request.id);
|
||||
|
||||
markRequestExecuted(request.id);
|
||||
assert.equal(findApprovedRequest(deployment.id, 'publish'), null, 'executed request is no longer approved');
|
||||
});
|
||||
|
||||
test('deciding an already-decided request returns null', () => {
|
||||
const request = createChangeRequest({ deploymentId: deployment.id, action: 'assign', requestedBy: owner });
|
||||
decideChangeRequest(request.id, { status: 'rejected', approverUserId: owner });
|
||||
assert.equal(decideChangeRequest(request.id, { status: 'approved', approverUserId: owner }), null);
|
||||
});
|
||||
|
||||
test('change requests are scoped to visible deployments for non-admins', () => {
|
||||
createChangeRequest({ deploymentId: deployment.id, action: 'relationships', requestedBy: owner });
|
||||
// deployment is shared, so a non-admin still sees its requests
|
||||
assert.ok(listChangeRequests(other, { isAdmin: false }).some((r) => r.deploymentId === deployment.id));
|
||||
});
|
||||
|
||||
test('reporting overview aggregates deployment status and install states', () => {
|
||||
const ts = new Date().toISOString();
|
||||
const insert = db.prepare(`
|
||||
INSERT INTO intune_deployment_statuses (id, deployment_id, graph_app_id, scope, install_state, error_code, created_at, updated_at)
|
||||
VALUES (?, ?, 'app-1', 'device', ?, ?, ?, ?)
|
||||
`);
|
||||
insert.run('ids_1', deployment.id, 'failed', '0x87D1041C', ts, ts);
|
||||
insert.run('ids_2', deployment.id, 'installed', '', ts, ts);
|
||||
insert.run('ids_3', deployment.id, 'failed', '0x87D1041C', ts, ts);
|
||||
|
||||
const overview = intuneReportingOverview(owner, false);
|
||||
const failed = overview.installStateCounts.find((row) => row.state === 'failed');
|
||||
assert.equal(failed.count, 2);
|
||||
assert.ok(overview.deploymentsByStatus.some((row) => row.status === 'ready'));
|
||||
assert.equal(overview.topErrors[0].code, '0x87D1041C');
|
||||
assert.equal(overview.topErrors[0].count, 2);
|
||||
});
|
||||
|
||||
test('reporting overview includes soak timers and a write-action trend', () => {
|
||||
const soaking = createIntuneDeployment({ name: 'Soak Report', visibility: 'shared', autoPromoteAfterHours: 24, autoPromoteRing: 'Broad' }, owner);
|
||||
db.prepare('UPDATE intune_deployments SET soak_started_at = ? WHERE id = ?')
|
||||
.run(new Date(Date.now() - 3_600_000).toISOString(), soaking.id);
|
||||
recordGraphAudit({ deploymentId: soaking.id, actorUserId: owner, action: 'win32app.publish', status: 'success', message: 'ok' });
|
||||
|
||||
const overview = intuneReportingOverview(owner, false);
|
||||
const timer = overview.soakTimers.find((row) => row.id === soaking.id);
|
||||
assert.ok(timer, 'soak timer present');
|
||||
assert.ok(timer.hoursRemaining > 22 && timer.hoursRemaining <= 24, `~23h remaining, got ${timer.hoursRemaining}`);
|
||||
assert.ok(overview.auditTrend.some((row) => row.status === 'success' && row.count >= 1));
|
||||
});
|
||||
44
server/test/graph.test.mjs
Normal file
44
server/test/graph.test.mjs
Normal file
@@ -0,0 +1,44 @@
|
||||
import './setup.mjs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { load } from './setup.mjs';
|
||||
|
||||
const { isAllowedGraphHost, isAllowedAuthorityHost } = await load('../data/graphHosts.js');
|
||||
const { graphConnectionSchema } = await load('../forms/schemas.js');
|
||||
|
||||
test('allow-list accepts Microsoft cloud hosts', () => {
|
||||
assert.ok(isAllowedGraphHost('https://graph.microsoft.com'));
|
||||
assert.ok(isAllowedGraphHost('https://graph.microsoft.us'));
|
||||
assert.ok(isAllowedAuthorityHost('https://login.microsoftonline.com'));
|
||||
});
|
||||
|
||||
test('allow-list rejects non-Microsoft and internal hosts', () => {
|
||||
assert.ok(!isAllowedGraphHost('https://graph.microsoft.com.evil.com'));
|
||||
assert.ok(!isAllowedGraphHost('http://169.254.169.254'));
|
||||
assert.ok(!isAllowedAuthorityHost('https://internal.corp.local'));
|
||||
assert.ok(!isAllowedGraphHost('https://graph.microsoft.com:8080@evil.com'));
|
||||
});
|
||||
|
||||
test('graph connection schema rejects SSRF endpoints', () => {
|
||||
const result = graphConnectionSchema.safeParse({
|
||||
name: 'Bad',
|
||||
tenantId: 'contoso',
|
||||
clientId: 'abc',
|
||||
clientSecret: 'shh',
|
||||
graphBaseUrl: 'http://169.254.169.254',
|
||||
authorityHost: 'https://login.microsoftonline.com'
|
||||
});
|
||||
assert.equal(result.success, false);
|
||||
});
|
||||
|
||||
test('graph connection schema accepts a valid Microsoft endpoint', () => {
|
||||
const result = graphConnectionSchema.safeParse({
|
||||
name: 'Good',
|
||||
tenantId: 'contoso',
|
||||
clientId: 'abc',
|
||||
clientSecret: 'shh',
|
||||
graphBaseUrl: 'https://graph.microsoft.com',
|
||||
authorityHost: 'https://login.microsoftonline.com'
|
||||
});
|
||||
assert.equal(result.success, true);
|
||||
});
|
||||
33
server/test/hosts.test.mjs
Normal file
33
server/test/hosts.test.mjs
Normal file
@@ -0,0 +1,33 @@
|
||||
import { adminUserId, makeUser, load } from './setup.mjs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
const { createHost, listHosts, updateHost, deleteHost, filterVisibleHostIds } = await load('../models/hostModel.js');
|
||||
|
||||
const owner = adminUserId();
|
||||
const other = makeUser({ email: 'other-host@test.local' });
|
||||
|
||||
test('personal hosts are visible only to their owner', () => {
|
||||
const host = createHost({ name: 'Private', address: 'priv.local', osFamily: 'windows', transport: 'winrm', tags: [], visibility: 'personal' }, owner);
|
||||
assert.ok(listHosts(owner).some((h) => h.id === host.id));
|
||||
assert.ok(!listHosts(other).some((h) => h.id === host.id));
|
||||
});
|
||||
|
||||
test('shared hosts are visible to everyone', () => {
|
||||
const host = createHost({ name: 'Shared', address: 'shared.local', osFamily: 'windows', transport: 'winrm', tags: [], visibility: 'shared' }, owner);
|
||||
assert.ok(listHosts(other).some((h) => h.id === host.id));
|
||||
});
|
||||
|
||||
test('a non-owner cannot update or delete a personal host', () => {
|
||||
const host = createHost({ name: 'Locked', address: 'locked.local', osFamily: 'windows', transport: 'winrm', tags: [], visibility: 'personal' }, owner);
|
||||
assert.equal(updateHost(host.id, { name: 'Hacked' }, other), null);
|
||||
deleteHost(host.id, other);
|
||||
assert.ok(listHosts(owner).some((h) => h.id === host.id), 'host should still exist after foreign delete attempt');
|
||||
});
|
||||
|
||||
test('filterVisibleHostIds drops hosts the user cannot see', () => {
|
||||
const priv = createHost({ name: 'OwnerOnly', address: 'owneronly.local', osFamily: 'windows', transport: 'winrm', tags: [], visibility: 'personal' }, owner);
|
||||
const shared = createHost({ name: 'Everyone', address: 'everyone.local', osFamily: 'windows', transport: 'winrm', tags: [], visibility: 'shared' }, owner);
|
||||
const visibleToOther = filterVisibleHostIds([priv.id, shared.id], other);
|
||||
assert.deepEqual(visibleToOther, [shared.id]);
|
||||
});
|
||||
162
server/test/intune.test.mjs
Normal file
162
server/test/intune.test.mjs
Normal file
@@ -0,0 +1,162 @@
|
||||
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');
|
||||
});
|
||||
107
server/test/intunewin.test.mjs
Normal file
107
server/test/intunewin.test.mjs
Normal file
@@ -0,0 +1,107 @@
|
||||
import './setup.mjs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { load } from './setup.mjs';
|
||||
|
||||
const { parseIntunewin } = await load('../services/intunewinParser.js');
|
||||
|
||||
// --- Minimal stored-method (no compression) ZIP builder for fixtures --------
|
||||
|
||||
function crc32(buf) {
|
||||
let crc = 0xffffffff;
|
||||
for (let i = 0; i < buf.length; i++) {
|
||||
crc ^= buf[i];
|
||||
for (let j = 0; j < 8; j++) crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1));
|
||||
}
|
||||
return (crc ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
function makeStoredZip(entries) {
|
||||
const locals = [];
|
||||
const centrals = [];
|
||||
let offset = 0;
|
||||
for (const { name, data } of entries) {
|
||||
const nameBuf = Buffer.from(name, 'utf8');
|
||||
const crc = crc32(data);
|
||||
const local = Buffer.alloc(30);
|
||||
local.writeUInt32LE(0x04034b50, 0);
|
||||
local.writeUInt16LE(20, 4);
|
||||
local.writeUInt16LE(0, 8); // method = stored
|
||||
local.writeUInt32LE(crc, 14);
|
||||
local.writeUInt32LE(data.length, 18);
|
||||
local.writeUInt32LE(data.length, 22);
|
||||
local.writeUInt16LE(nameBuf.length, 26);
|
||||
const localRecord = Buffer.concat([local, nameBuf, data]);
|
||||
|
||||
const central = Buffer.alloc(46);
|
||||
central.writeUInt32LE(0x02014b50, 0);
|
||||
central.writeUInt16LE(20, 4);
|
||||
central.writeUInt16LE(20, 6);
|
||||
central.writeUInt16LE(0, 10); // method = stored
|
||||
central.writeUInt32LE(crc, 16);
|
||||
central.writeUInt32LE(data.length, 20);
|
||||
central.writeUInt32LE(data.length, 24);
|
||||
central.writeUInt16LE(nameBuf.length, 28);
|
||||
central.writeUInt32LE(offset, 42);
|
||||
centrals.push(Buffer.concat([central, nameBuf]));
|
||||
|
||||
locals.push(localRecord);
|
||||
offset += localRecord.length;
|
||||
}
|
||||
const cd = Buffer.concat(centrals);
|
||||
const localsBuf = Buffer.concat(locals);
|
||||
const eocd = Buffer.alloc(22);
|
||||
eocd.writeUInt32LE(0x06054b50, 0);
|
||||
eocd.writeUInt16LE(entries.length, 8);
|
||||
eocd.writeUInt16LE(entries.length, 10);
|
||||
eocd.writeUInt32LE(cd.length, 12);
|
||||
eocd.writeUInt32LE(localsBuf.length, 16);
|
||||
return Buffer.concat([localsBuf, cd, eocd]);
|
||||
}
|
||||
|
||||
const detectionXml = `<?xml version="1.0" encoding="utf-8"?>
|
||||
<ApplicationInfo ToolVersion="1.8.6">
|
||||
<Name>Contoso VPN</Name>
|
||||
<UnencryptedContentSize>2048</UnencryptedContentSize>
|
||||
<FileName>IntunePackage.intunewin</FileName>
|
||||
<SetupFile>ContosoVpnSetup.exe</SetupFile>
|
||||
<EncryptionInfo>
|
||||
<EncryptionKey>QUJDRA==</EncryptionKey>
|
||||
<MacKey>RUZHSA==</MacKey>
|
||||
<InitializationVector>SUpLTA==</InitializationVector>
|
||||
<Mac>TU5PUA==</Mac>
|
||||
<ProfileIdentifier>ProfileVersion1</ProfileIdentifier>
|
||||
<FileDigest>UVJTVA==</FileDigest>
|
||||
<FileDigestAlgorithm>SHA256</FileDigestAlgorithm>
|
||||
</EncryptionInfo>
|
||||
</ApplicationInfo>`;
|
||||
|
||||
const encryptedPayload = Buffer.from('this-stands-in-for-the-encrypted-intunewin-bytes');
|
||||
|
||||
const fixture = makeStoredZip([
|
||||
{ name: 'IntuneWinPackage/Metadata/Detection.xml', data: Buffer.from(detectionXml, 'utf8') },
|
||||
{ name: 'IntuneWinPackage/Contents/IntunePackage.intunewin', data: encryptedPayload }
|
||||
]);
|
||||
|
||||
test('parses setup file, sizes, and encrypted payload', () => {
|
||||
const parsed = parseIntunewin(fixture);
|
||||
assert.equal(parsed.setupFile, 'ContosoVpnSetup.exe');
|
||||
assert.equal(parsed.unencryptedContentSize, 2048);
|
||||
assert.equal(parsed.encryptedContentSize, encryptedPayload.length);
|
||||
assert.ok(parsed.encryptedContent.equals(encryptedPayload));
|
||||
});
|
||||
|
||||
test('maps EncryptionInfo to Graph fileEncryptionInfo', () => {
|
||||
const { fileEncryptionInfo } = parseIntunewin(fixture);
|
||||
assert.equal(fileEncryptionInfo.encryptionKey, 'QUJDRA==');
|
||||
assert.equal(fileEncryptionInfo.macKey, 'RUZHSA==');
|
||||
assert.equal(fileEncryptionInfo.initializationVector, 'SUpLTA==');
|
||||
assert.equal(fileEncryptionInfo.mac, 'TU5PUA==');
|
||||
assert.equal(fileEncryptionInfo.profileIdentifier, 'ProfileVersion1');
|
||||
assert.equal(fileEncryptionInfo.fileDigest, 'UVJTVA==');
|
||||
assert.equal(fileEncryptionInfo.fileDigestAlgorithm, 'SHA256');
|
||||
});
|
||||
|
||||
test('rejects a non-.intunewin buffer', () => {
|
||||
assert.throws(() => parseIntunewin(Buffer.from('not a zip at all')));
|
||||
});
|
||||
37
server/test/jobs.test.mjs
Normal file
37
server/test/jobs.test.mjs
Normal file
@@ -0,0 +1,37 @@
|
||||
import { db, makeUser, adminUserId, load } from './setup.mjs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
const { listJobs, getJob } = await load('../models/jobModel.js');
|
||||
|
||||
const owner = makeUser({ email: 'job-owner@test.local' });
|
||||
const stranger = makeUser({ email: 'job-stranger@test.local' });
|
||||
const admin = adminUserId();
|
||||
|
||||
// Build a personal RunPlan owned by `owner` and a job they triggered, directly
|
||||
// in SQL so the test never invokes PowerShell.
|
||||
const ts = new Date().toISOString();
|
||||
const scriptId = 'scr_job_test';
|
||||
db.prepare(`INSERT INTO scripts (id, name, content, visibility, owner_user_id, version, created_at, updated_at)
|
||||
VALUES (?, 'Job Test', 'Write-Output 1', 'personal', ?, 1, ?, ?)`).run(scriptId, owner, ts, ts);
|
||||
const runplanId = 'run_job_test';
|
||||
db.prepare(`INSERT INTO runplans (id, name, script_id, visibility, owner_user_id, parallel, created_at, updated_at)
|
||||
VALUES (?, 'Job Test Plan', ?, 'personal', ?, 1, ?, ?)`).run(runplanId, scriptId, owner, ts, ts);
|
||||
const jobId = 'job_test_1';
|
||||
db.prepare(`INSERT INTO jobs (id, runplan_id, script_id, status, triggered_by, created_at)
|
||||
VALUES (?, ?, ?, 'completed', ?, ?)`).run(jobId, runplanId, scriptId, owner, ts);
|
||||
|
||||
test('the triggering operator can list and read their job', () => {
|
||||
assert.ok(listJobs(owner, false).some((j) => j.id === jobId));
|
||||
assert.ok(getJob(jobId, owner, false));
|
||||
});
|
||||
|
||||
test('an unrelated user cannot see a personal job', () => {
|
||||
assert.ok(!listJobs(stranger, false).some((j) => j.id === jobId));
|
||||
assert.equal(getJob(jobId, stranger, false), null);
|
||||
});
|
||||
|
||||
test('admins can see any job', () => {
|
||||
assert.ok(listJobs(admin, true).some((j) => j.id === jobId));
|
||||
assert.ok(getJob(jobId, admin, true));
|
||||
});
|
||||
65
server/test/library.test.mjs
Normal file
65
server/test/library.test.mjs
Normal file
@@ -0,0 +1,65 @@
|
||||
import { makeUser, load } from './setup.mjs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
const { createScript, cloneScript, scriptUsage } = await load('../models/libraryModel.js');
|
||||
const { createRunPlan } = await load('../models/runPlanModel.js');
|
||||
const { createIntuneDeployment } = await load('../models/psadtModel.js');
|
||||
|
||||
const owner = makeUser({ email: 'library-owner@test.local' });
|
||||
|
||||
test('scriptUsage reports linked RunPlans and Intune deployments', () => {
|
||||
const script = createScript({
|
||||
name: 'Usage Target.ps1',
|
||||
description: 'Used by operational plans',
|
||||
content: 'Write-Output usage',
|
||||
visibility: 'personal'
|
||||
}, owner);
|
||||
|
||||
const runplan = createRunPlan({
|
||||
name: 'Usage RunPlan',
|
||||
description: '',
|
||||
scriptId: script.id,
|
||||
visibility: 'personal',
|
||||
parallel: true,
|
||||
hostIds: []
|
||||
}, owner);
|
||||
|
||||
const deployment = createIntuneDeployment({
|
||||
name: 'Usage Intune Plan',
|
||||
scriptId: script.id,
|
||||
installCommand: 'Invoke-AppDeployToolkit.exe -DeploymentType Install',
|
||||
uninstallCommand: 'Invoke-AppDeployToolkit.exe -DeploymentType Uninstall',
|
||||
visibility: 'personal'
|
||||
}, owner);
|
||||
|
||||
const usage = scriptUsage(script.id, owner);
|
||||
assert.equal(usage.total, 2);
|
||||
assert.deepEqual(usage.runplans.map((item) => item.id), [runplan.id]);
|
||||
assert.deepEqual(usage.intuneDeployments.map((item) => item.id), [deployment.id]);
|
||||
});
|
||||
|
||||
test('cloneScript creates an independent unassigned script copy', () => {
|
||||
const script = createScript({
|
||||
name: 'Clone Source.ps1',
|
||||
description: 'Original',
|
||||
content: 'Write-Output clone',
|
||||
visibility: 'personal'
|
||||
}, owner);
|
||||
|
||||
createRunPlan({
|
||||
name: 'Original Only',
|
||||
description: '',
|
||||
scriptId: script.id,
|
||||
visibility: 'personal',
|
||||
parallel: true,
|
||||
hostIds: []
|
||||
}, owner);
|
||||
|
||||
const clone = cloneScript(script.id, { name: 'Clone Target.ps1' }, owner);
|
||||
assert.notEqual(clone.id, script.id);
|
||||
assert.equal(clone.name, 'Clone Target.ps1');
|
||||
assert.equal(clone.content, script.content);
|
||||
assert.equal(scriptUsage(clone.id, owner).total, 0);
|
||||
assert.equal(scriptUsage(script.id, owner).total, 1);
|
||||
});
|
||||
80
server/test/packaging.test.mjs
Normal file
80
server/test/packaging.test.mjs
Normal file
@@ -0,0 +1,80 @@
|
||||
import './setup.mjs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { load } from './setup.mjs';
|
||||
|
||||
const { analyzeInstaller, listInstallerTechnologies } = await load('../services/installerIntelService.js');
|
||||
const { buildDetectionRule } = await load('../services/detectionRuleService.js');
|
||||
|
||||
// --- installer detection + silent switches --------------------------------
|
||||
|
||||
test('MSI is detected with high confidence and product-code uninstall', () => {
|
||||
const result = analyzeInstaller({ fileName: 'ContosoVpn.msi', productCode: '{12345678-1234-1234-1234-123456789012}' });
|
||||
assert.equal(result.confidence, 'high');
|
||||
assert.equal(result.primary.id, 'msi');
|
||||
assert.ok(result.primary.install.includes('/qn'));
|
||||
assert.ok(result.primary.uninstall.includes('{12345678-1234-1234-1234-123456789012}'));
|
||||
});
|
||||
|
||||
test('MSIX is detected from its extension', () => {
|
||||
const result = analyzeInstaller({ fileName: 'App_1.0.0.0_x64.msixbundle' });
|
||||
assert.equal(result.primary.id, 'msix');
|
||||
assert.ok(result.primary.install.includes('Add-AppxProvisionedPackage'));
|
||||
});
|
||||
|
||||
test('EXE with a name hint matches the technology at medium confidence', () => {
|
||||
const result = analyzeInstaller({ fileName: 'app-nsis-setup.exe' });
|
||||
assert.equal(result.confidence, 'medium');
|
||||
assert.equal(result.primary.id, 'nsis');
|
||||
assert.equal(result.primary.install, '"app-nsis-setup.exe" /S');
|
||||
});
|
||||
|
||||
test('ambiguous EXE falls back to generic and offers candidates', () => {
|
||||
const result = analyzeInstaller({ fileName: 'installer.exe' });
|
||||
assert.equal(result.confidence, 'low');
|
||||
assert.equal(result.primary.id, 'exe');
|
||||
const ids = result.candidates.map((c) => c.id);
|
||||
assert.ok(ids.includes('inno') && ids.includes('installshield'));
|
||||
assert.equal(ids[ids.length - 1], 'exe', 'generic EXE is offered last');
|
||||
});
|
||||
|
||||
test('unknown extension reports unknown', () => {
|
||||
const result = analyzeInstaller({ fileName: 'notes.txt' });
|
||||
assert.equal(result.confidence, 'unknown');
|
||||
assert.equal(result.primary, null);
|
||||
});
|
||||
|
||||
test('listInstallerTechnologies exposes the catalog', () => {
|
||||
const list = listInstallerTechnologies();
|
||||
assert.ok(list.find((t) => t.id === 'inno'));
|
||||
assert.ok(list.every((t) => Array.isArray(t.extensions)));
|
||||
});
|
||||
|
||||
// --- detection rule generation --------------------------------------------
|
||||
|
||||
test('MSI detection maps to a product-code rule', () => {
|
||||
const rule = buildDetectionRule({ type: 'msi', productCode: '{abc}' });
|
||||
assert.equal(rule.detectionType, 'msi-product-code');
|
||||
assert.equal(rule.detectionRule, '{abc}');
|
||||
});
|
||||
|
||||
test('file detection generates a versioned PowerShell script', () => {
|
||||
const rule = buildDetectionRule({ type: 'file', path: 'C:\\Program Files\\App\\app.exe', version: '2.0.0' });
|
||||
assert.equal(rule.detectionType, 'custom-script');
|
||||
assert.ok(rule.detectionRule.includes('Test-Path'));
|
||||
assert.ok(rule.detectionRule.includes("[version]'2.0.0'"));
|
||||
assert.ok(rule.detectionRule.includes("Write-Output 'Detected'"));
|
||||
});
|
||||
|
||||
test('registry detection with a value comparison', () => {
|
||||
const rule = buildDetectionRule({ type: 'registry', keyPath: 'HKLM:\\SOFTWARE\\Contoso', valueName: 'Version', valueData: '2.0' });
|
||||
assert.equal(rule.detectionType, 'custom-script');
|
||||
assert.ok(rule.detectionRule.includes('HKLM:\\SOFTWARE\\Contoso'));
|
||||
assert.ok(rule.detectionRule.includes("-eq '2.0'"));
|
||||
});
|
||||
|
||||
test('detection generator validates required inputs', () => {
|
||||
assert.throws(() => buildDetectionRule({ type: 'msi' }));
|
||||
assert.throws(() => buildDetectionRule({ type: 'file' }));
|
||||
assert.throws(() => buildDetectionRule({ type: 'bogus' }));
|
||||
});
|
||||
53
server/test/promotion.test.mjs
Normal file
53
server/test/promotion.test.mjs
Normal file
@@ -0,0 +1,53 @@
|
||||
import './setup.mjs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { load } from './setup.mjs';
|
||||
|
||||
const { bumpVersion, cloneForNextVersion, setRingIntent } = await load('../services/intunePromotionService.js');
|
||||
|
||||
test('bumpVersion increments the trailing number', () => {
|
||||
assert.equal(bumpVersion('1.2.3'), '1.2.4');
|
||||
assert.equal(bumpVersion('126'), '127');
|
||||
assert.equal(bumpVersion('3.1.0-x64'), '3.1.0-x65'); // bumps the last numeric run
|
||||
assert.equal(bumpVersion(''), '1.0.0');
|
||||
assert.equal(bumpVersion('stable'), 'stable.1');
|
||||
});
|
||||
|
||||
test('cloneForNextVersion produces an unpublished next-version plan', () => {
|
||||
const source = {
|
||||
name: 'Contoso VPN 3.1.0',
|
||||
profileId: 'p1', applicationId: 'app1', commandStyle: 'v4', installBehavior: 'system',
|
||||
restartBehavior: 'return-code', uiMode: 'native-v4', requirements: { architecture: 'x64' },
|
||||
installCommand: 'i', uninstallCommand: 'u', detectionType: 'msi-product-code', detectionRule: '{x}',
|
||||
returnCodes: [{ code: 0, type: 'success' }], assignments: [{ ring: 'Pilot', intent: 'available' }],
|
||||
relationships: [], graphAppId: 'live-app', status: 'published', visibility: 'shared'
|
||||
};
|
||||
const clone = cloneForNextVersion(source, { version: '3.2.0' });
|
||||
assert.equal(clone.name, 'Contoso VPN 3.2.0');
|
||||
assert.equal(clone.graphAppId, '');
|
||||
assert.equal(clone.status, 'draft');
|
||||
assert.equal(clone.applicationId, 'app1');
|
||||
assert.equal(clone.detectionRule, '{x}');
|
||||
assert.deepEqual(clone.assignments, source.assignments);
|
||||
});
|
||||
|
||||
test('cloneForNextVersion auto-bumps the version from the name when not given', () => {
|
||||
const clone = cloneForNextVersion({ name: 'Firefox 120.0', visibility: 'shared' }, {});
|
||||
assert.equal(clone.name, 'Firefox 120.1');
|
||||
});
|
||||
|
||||
test('setRingIntent promotes only the named ring and reports change', () => {
|
||||
const assignments = [
|
||||
{ ring: 'Pilot', intent: 'available' },
|
||||
{ ring: 'Broad', intent: 'available' }
|
||||
];
|
||||
const result = setRingIntent(assignments, 'Broad', 'required');
|
||||
assert.equal(result.changed, true);
|
||||
assert.equal(result.assignments.find((a) => a.ring === 'Broad').intent, 'required');
|
||||
assert.equal(result.assignments.find((a) => a.ring === 'Pilot').intent, 'available');
|
||||
});
|
||||
|
||||
test('setRingIntent is a no-op for unknown rings', () => {
|
||||
const result = setRingIntent([{ ring: 'Pilot', intent: 'available' }], 'Nope', 'required');
|
||||
assert.equal(result.changed, false);
|
||||
});
|
||||
38
server/test/settings.test.mjs
Normal file
38
server/test/settings.test.mjs
Normal file
@@ -0,0 +1,38 @@
|
||||
import { db, load } from './setup.mjs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
const { getSetting, getBooleanSetting, updateSettings, effectiveTrustedOrigins } = await load('../models/settingsModel.js');
|
||||
|
||||
test('unpinned settings seed as editable defaults', () => {
|
||||
// No SERVER_FQDN env var is set in the test environment, so server_fqdn is a
|
||||
// default-sourced, admin-editable setting.
|
||||
const source = db.prepare('SELECT source FROM settings WHERE key = ?').get('server_fqdn')?.source;
|
||||
assert.equal(source, 'default');
|
||||
});
|
||||
|
||||
test('structural settings are env-pinned and locked', () => {
|
||||
const source = db.prepare('SELECT source FROM settings WHERE key = ?').get('database_path')?.source;
|
||||
assert.equal(source, 'env');
|
||||
});
|
||||
|
||||
test('updateSettings persists editable keys but rejects env-pinned keys', () => {
|
||||
updateSettings({ server_fqdn: 'https://posh.example.com', database_path: '/evil/path.sqlite' });
|
||||
assert.equal(getSetting('server_fqdn'), 'https://posh.example.com');
|
||||
// The env-pinned database_path must be unchanged.
|
||||
assert.notEqual(getSetting('database_path'), '/evil/path.sqlite');
|
||||
});
|
||||
|
||||
test('allow_script_execution is readable as an effective boolean', () => {
|
||||
updateSettings({ allow_script_execution: 'false' });
|
||||
assert.equal(getBooleanSetting('allow_script_execution', true), false);
|
||||
updateSettings({ allow_script_execution: 'true' });
|
||||
assert.equal(getBooleanSetting('allow_script_execution', false), true);
|
||||
});
|
||||
|
||||
test('effectiveTrustedOrigins merges admin edits with built-in defaults', () => {
|
||||
updateSettings({ trusted_origins: 'https://ops.example.com' });
|
||||
const origins = effectiveTrustedOrigins();
|
||||
assert.ok(origins.includes('https://ops.example.com'));
|
||||
assert.ok(origins.includes('http://localhost:3000'));
|
||||
});
|
||||
42
server/test/setup.mjs
Normal file
42
server/test/setup.mjs
Normal file
@@ -0,0 +1,42 @@
|
||||
// Shared test bootstrap. Sets up an isolated temp SQLite database and storage
|
||||
// dirs BEFORE any application module (which reads config at import time) is
|
||||
// loaded, then migrates + seeds a fresh schema. Import this first, then use
|
||||
// `load()` to dynamically import the modules under test.
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'poshm-test-'));
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.DATABASE_PATH = path.join(tmpRoot, 'test.sqlite');
|
||||
process.env.LOG_DIR = path.join(tmpRoot, 'logs');
|
||||
process.env.FILE_LOCKER_DIR = path.join(tmpRoot, 'filelocker');
|
||||
process.env.JWT_SECRET = `test-${crypto.randomBytes(8).toString('hex')}`;
|
||||
process.env.CREDENTIAL_STORE_KEY = `cred-${crypto.randomBytes(8).toString('hex')}`;
|
||||
// Leave entra/origin/allow_script_execution env unset so tests exercise the
|
||||
// unpinned editable-default code paths. Tests never invoke PowerShell.
|
||||
|
||||
const dbModule = await import('../db.js');
|
||||
dbModule.migrate();
|
||||
dbModule.seed();
|
||||
|
||||
export const db = dbModule.db;
|
||||
|
||||
export async function load(modulePath) {
|
||||
return import(modulePath);
|
||||
}
|
||||
|
||||
export function adminUserId() {
|
||||
return db.prepare('SELECT id FROM users WHERE role = ? LIMIT 1').get('admin').id;
|
||||
}
|
||||
|
||||
export function makeUser({ email, role = 'user' } = {}) {
|
||||
const id = `usr_test_${crypto.randomBytes(5).toString('hex')}`;
|
||||
db.prepare(`
|
||||
INSERT INTO users (id, email, display_name, password_hash, auth_provider, role, created_at)
|
||||
VALUES (?, ?, ?, NULL, 'local', ?, ?)
|
||||
`).run(id, email || `${id}@test.local`, id, role, new Date().toISOString());
|
||||
return id;
|
||||
}
|
||||
Reference in New Issue
Block a user