This commit is contained in:
2026-06-25 20:57:09 -05:00
parent 552b86c323
commit e47d09d7d6
35 changed files with 4507 additions and 101 deletions

View File

@@ -0,0 +1,159 @@
import { db, load, makeUser } from './setup.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
const { createHost } = await load('../models/hostModel.js');
const { createCredential } = await load('../models/credentialModel.js');
const { updateSettings } = await load('../models/settingsModel.js');
const { generateJobOutputs } = await load('../services/jobOutputService.js');
const { getJobOutputFile, listJobOutputs, listJobOutputsForJob } = await load('../models/jobOutputModel.js');
const { executeScriptTest } = await load('../services/powershellRunner.js');
const owner = makeUser({ email: 'job-output-owner@test.local' });
const stranger = makeUser({ email: 'job-output-stranger@test.local' });
const ts = new Date().toISOString();
function addLog(jobId, hostId, stream, line, offset = 0) {
db.prepare(`
INSERT INTO job_logs (id, job_id, host_id, stream, line, created_at)
VALUES (?, ?, ?, ?, ?, ?)
`).run(`log_output_${jobId}_${hostId}_${stream}_${offset}`, jobId, hostId, stream, line, new Date(Date.parse(ts) + offset).toISOString());
}
test('job output generation writes stdout PDF, datatable JSON, and terminal transcript artifacts', async () => {
const scriptId = 'scr_job_output_test';
const jobId = 'job_output_test';
db.prepare(`INSERT INTO scripts (id, name, content, visibility, owner_user_id, version, created_at, updated_at)
VALUES (?, 'Inventory Table.ps1', 'Get-Process', 'personal', ?, 1, ?, ?)`).run(scriptId, owner, ts, ts);
const hostA = createHost({ name: 'Output Host A', address: 'host-a.local', transport: 'local', osFamily: 'linux', visibility: 'personal' }, owner);
const hostB = createHost({ name: 'Output Host B', address: 'host-b.local', transport: 'local', osFamily: 'linux', visibility: 'personal' }, owner);
db.prepare(`INSERT INTO jobs (id, runplan_id, script_id, status, triggered_by, started_at, ended_at, created_at)
VALUES (?, NULL, ?, 'completed', ?, ?, ?, ?)`).run(jobId, scriptId, owner, ts, ts, ts);
db.prepare(`INSERT INTO job_hosts (id, job_id, host_id, status, exit_code, started_at, ended_at)
VALUES (?, ?, ?, 'completed', 0, ?, ?)`).run('jhost_output_a', jobId, hostA.id, ts, ts);
db.prepare(`INSERT INTO job_hosts (id, job_id, host_id, status, exit_code, started_at, ended_at)
VALUES (?, ?, ?, 'completed', 0, ?, ?)`).run('jhost_output_b', jobId, hostB.id, ts, ts);
addLog(jobId, hostA.id, 'stdout', 'Name,Status', 1);
addLog(jobId, hostA.id, 'stdout', 'Spooler,Running', 2);
addLog(jobId, hostB.id, 'stdout', 'Name,Status', 3);
addLog(jobId, hostB.id, 'stdout', 'WinRM,Running', 4);
addLog(jobId, hostB.id, 'stderr', 'Ignored by stdout PDF', 5);
await generateJobOutputs(jobId);
const outputs = listJobOutputsForJob(jobId, owner, false);
const kinds = outputs.map((output) => output.kind).sort();
assert.deepEqual(kinds, ['datatable', 'stdout-pdf', 'terminal']);
assert.equal(listJobOutputs(stranger, false).some((output) => output.jobId === jobId), false);
const tableOutput = outputs.find((output) => output.kind === 'datatable');
const tableFile = getJobOutputFile(tableOutput.id, owner, false);
const table = JSON.parse(fs.readFileSync(tableFile.filePath, 'utf8'));
assert.deepEqual(table.columns.map((column) => column.key), ['Host', 'HostStatus', 'Name', 'Status']);
assert.equal(table.rows.length, 2);
assert.ok(table.rows.some((row) => row.Host === 'Output Host A' && row.HostStatus === 'completed' && row.Name === 'Spooler' && row.Status === 'Running'));
assert.ok(table.rows.some((row) => row.Host === 'Output Host B' && row.HostStatus === 'completed' && row.Name === 'WinRM' && row.Status === 'Running'));
const pdfOutput = outputs.find((output) => output.kind === 'stdout-pdf');
const pdfFile = getJobOutputFile(pdfOutput.id, owner, false);
assert.equal(pdfOutput.mimeType, 'application/pdf');
assert.equal(fs.readFileSync(pdfFile.filePath).subarray(0, 4).toString(), '%PDF');
const terminalOutput = outputs.find((output) => output.kind === 'terminal');
const terminalFile = getJobOutputFile(terminalOutput.id, owner, false);
const transcript = fs.readFileSync(terminalFile.filePath, 'utf8');
assert.match(transcript, /Ignored by stdout PDF/);
});
test('script test runs generate the same FileLocker job output artifacts', async () => {
const scriptOwner = makeUser({ email: 'job-output-script-test@test.local' });
const scriptId = 'scr_job_output_script_test';
db.prepare(`INSERT INTO scripts (id, name, content, visibility, owner_user_id, version, created_at, updated_at)
VALUES (?, 'Script Test Output.ps1', 'Write-Output test', 'personal', ?, 1, ?, ?)`).run(scriptId, scriptOwner, ts, ts);
const credential = createCredential({
name: 'Output Test Credential',
kind: 'username_password',
username: 'TEST\\svc',
secret: 'secret',
visibility: 'personal'
}, scriptOwner);
const host = createHost({
name: 'Output Test Host',
address: 'output-test.local',
transport: 'local',
osFamily: 'linux',
credentialId: credential.id,
visibility: 'personal'
}, scriptOwner);
updateSettings({ allow_script_execution: 'false' });
const job = executeScriptTest(scriptId, { hostId: host.id, credentialId: credential.id }, scriptOwner);
await new Promise((resolve) => setTimeout(resolve, 50));
const outputs = listJobOutputsForJob(job.id, scriptOwner, false);
const kinds = outputs.map((output) => output.kind).sort();
assert.deepEqual(kinds, ['datatable', 'stdout-pdf', 'terminal']);
updateSettings({ allow_script_execution: 'true' });
});
test('job output datatable uses one last-stdout row per host for unstructured output', async () => {
const scriptId = 'scr_job_output_last_line_test';
const jobId = 'job_output_last_line_test';
db.prepare(`INSERT INTO scripts (id, name, content, visibility, owner_user_id, version, created_at, updated_at)
VALUES (?, 'Last Line.ps1', 'Write-Output lines', 'personal', ?, 1, ?, ?)`).run(scriptId, owner, ts, ts);
const hostA = createHost({ name: 'Last Host A', address: 'last-a.local', transport: 'local', osFamily: 'linux', visibility: 'personal' }, owner);
const hostB = createHost({ name: 'Last Host B', address: 'last-b.local', transport: 'local', osFamily: 'linux', visibility: 'personal' }, owner);
db.prepare(`INSERT INTO jobs (id, runplan_id, script_id, status, triggered_by, started_at, ended_at, created_at)
VALUES (?, NULL, ?, 'completed', ?, ?, ?, ?)`).run(jobId, scriptId, owner, ts, ts, ts);
db.prepare(`INSERT INTO job_hosts (id, job_id, host_id, status, exit_code, started_at, ended_at)
VALUES (?, ?, ?, 'completed', 0, ?, ?)`).run('jhost_last_a', jobId, hostA.id, ts, ts);
db.prepare(`INSERT INTO job_hosts (id, job_id, host_id, status, exit_code, started_at, ended_at)
VALUES (?, ?, ?, 'completed', 0, ?, ?)`).run('jhost_last_b', jobId, hostB.id, ts, ts);
addLog(jobId, hostA.id, 'stdout', 'starting host A', 1);
addLog(jobId, hostA.id, 'stdout', 'final host A', 2);
addLog(jobId, hostB.id, 'stdout', 'starting host B', 3);
addLog(jobId, hostB.id, 'stdout', '', 4);
addLog(jobId, hostB.id, 'stdout', 'final host B', 5);
await generateJobOutputs(jobId);
const tableOutput = listJobOutputsForJob(jobId, owner, false).find((output) => output.kind === 'datatable');
const tableFile = getJobOutputFile(tableOutput.id, owner, false);
const table = JSON.parse(fs.readFileSync(tableFile.filePath, 'utf8'));
assert.deepEqual(table.parsers, ['stdout-last-line']);
assert.deepEqual(table.columns.map((column) => column.key), ['Host', 'HostStatus', 'Output']);
assert.equal(table.rows.length, 2);
assert.ok(table.rows.some((row) => row.Host === 'Last Host A' && row.Output === 'final host A'));
assert.ok(table.rows.some((row) => row.Host === 'Last Host B' && row.Output === 'final host B'));
assert.equal(table.rows.some((row) => row.Output === 'starting host A' || row.Output === 'starting host B'), false);
});
test('job output datatable carries fields for structured object and array stdout', async () => {
const scriptId = 'scr_job_output_structured_test';
const jobId = 'job_output_structured_test';
db.prepare(`INSERT INTO scripts (id, name, content, visibility, owner_user_id, version, created_at, updated_at)
VALUES (?, 'Structured.ps1', 'ConvertTo-Json', 'personal', ?, 1, ?, ?)`).run(scriptId, owner, ts, ts);
const hostA = createHost({ name: 'Structured Host A', address: 'structured-a.local', transport: 'local', osFamily: 'linux', visibility: 'personal' }, owner);
const hostB = createHost({ name: 'Structured Host B', address: 'structured-b.local', transport: 'local', osFamily: 'linux', visibility: 'personal' }, owner);
db.prepare(`INSERT INTO jobs (id, runplan_id, script_id, status, triggered_by, started_at, ended_at, created_at)
VALUES (?, NULL, ?, 'completed', ?, ?, ?, ?)`).run(jobId, scriptId, owner, ts, ts, ts);
db.prepare(`INSERT INTO job_hosts (id, job_id, host_id, status, exit_code, started_at, ended_at)
VALUES (?, ?, ?, 'completed', 0, ?, ?)`).run('jhost_structured_a', jobId, hostA.id, ts, ts);
db.prepare(`INSERT INTO job_hosts (id, job_id, host_id, status, exit_code, started_at, ended_at)
VALUES (?, ?, ?, 'completed', 0, ?, ?)`).run('jhost_structured_b', jobId, hostB.id, ts, ts);
addLog(jobId, hostA.id, 'stdout', '{"Name":"SvcA","Status":"Running","Version":1}', 1);
addLog(jobId, hostB.id, 'stdout', '[{"Name":"SvcB","Status":"Running"},{"Name":"SvcC","Status":"Stopped"}]', 2);
await generateJobOutputs(jobId);
const tableOutput = listJobOutputsForJob(jobId, owner, false).find((output) => output.kind === 'datatable');
const tableFile = getJobOutputFile(tableOutput.id, owner, false);
const table = JSON.parse(fs.readFileSync(tableFile.filePath, 'utf8'));
assert.deepEqual(table.parsers, ['json']);
assert.deepEqual(table.columns.map((column) => column.key), ['Host', 'HostStatus', 'Name', 'Status', 'Version']);
assert.equal(table.rows.length, 3);
assert.ok(table.rows.some((row) => row.Host === 'Structured Host A' && row.Name === 'SvcA' && row.Version === '1'));
assert.ok(table.rows.some((row) => row.Host === 'Structured Host B' && row.Name === 'SvcB' && row.Status === 'Running'));
assert.ok(table.rows.some((row) => row.Host === 'Structured Host B' && row.Name === 'SvcC' && row.Status === 'Stopped'));
});

View File

@@ -4,6 +4,10 @@ import assert from 'node:assert/strict';
const { listJobs, getJob } = await load('../models/jobModel.js');
const { cancelSystemJob, listSystemJobs, runSystemJob } = await load('../services/systemJobService.js');
const { createCredential } = await load('../models/credentialModel.js');
const { createHost } = await load('../models/hostModel.js');
const { updateSettings } = await load('../models/settingsModel.js');
const { buildWrapper, executeScriptTest } = await load('../services/powershellRunner.js');
const owner = makeUser({ email: 'job-owner@test.local' });
const stranger = makeUser({ email: 'job-stranger@test.local' });
@@ -52,6 +56,68 @@ test('admins can see any job', () => {
assert.ok(getJob(jobId, admin, true));
});
test('executeScriptTest creates job host rows before runner starts', async () => {
const scriptOwner = makeUser({ email: 'script-test-owner@test.local' });
const localScriptId = 'scr_execute_test_1';
db.prepare(`INSERT INTO scripts (id, name, content, visibility, owner_user_id, version, created_at, updated_at)
VALUES (?, 'Script Execute Test', 'Write-Output test', 'personal', ?, 1, ?, ?)`).run(localScriptId, scriptOwner, ts, ts);
const credential = createCredential({
name: 'Script Test Credential',
kind: 'username_password',
username: 'TEST\\svc',
secret: 'secret',
visibility: 'personal'
}, scriptOwner);
const host = createHost({
name: 'Script Test Host',
address: 'script-test.local',
fqdn: 'script-test.local',
osFamily: 'windows',
transport: 'winrm',
credentialId: credential.id,
visibility: 'personal',
tags: []
}, scriptOwner);
updateSettings({ allow_script_execution: 'false' });
const job = executeScriptTest(localScriptId, { hostId: host.id, credentialId: credential.id }, scriptOwner);
await new Promise((resolve) => setTimeout(resolve, 25));
const jobHost = db.prepare('SELECT * FROM job_hosts WHERE job_id = ? AND host_id = ?').get(job.id, host.id);
const errorLog = db.prepare('SELECT * FROM job_logs WHERE job_id = ? AND stream = ? AND line LIKE ?').get(job.id, 'stderr', '%Script execution is disabled%');
assert.equal(jobHost.status, 'failed');
assert.ok(errorLog);
updateSettings({ allow_script_execution: 'true' });
});
test('PowerShell wrappers fail the host when Invoke-Command writes a terminating error', () => {
const wrapper = buildWrapper('Write-Output "remote smoke"', {
id: 'hst_wrapper_winrm',
name: 'Wrapper WinRM',
address: 'dallas.pucknet.local',
transport: 'winrm'
});
assert.match(wrapper, /\$ErrorActionPreference = "Stop"/);
assert.match(wrapper, /Invoke-Command -ComputerName 'dallas\.pucknet\.local'.*-ErrorAction Stop/);
assert.match(wrapper, /catch \{/);
assert.match(wrapper, /exit 1/);
assert.doesNotMatch(wrapper, /\$ErrorActionPreference = "Continue"/);
});
test('local PowerShell wrappers propagate native command failures', () => {
const wrapper = buildWrapper('Write-Output "local smoke"', {
id: 'hst_wrapper_local',
name: 'Wrapper Local',
address: 'localhost',
transport: 'local'
});
assert.match(wrapper, /\$global:LASTEXITCODE/);
assert.match(wrapper, /exit \$global:LASTEXITCODE/);
assert.match(wrapper, /exit 0/);
});
test('system jobs list execution and background jobs', () => {
const listJobId = 'job_list_system_test_1';
db.prepare(`INSERT INTO jobs (id, runplan_id, script_id, status, triggered_by, started_at, created_at)

View File

@@ -0,0 +1,89 @@
import { db, load, makeUser } from './setup.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
const { createCredential } = await load('../models/credentialModel.js');
const { createHost } = await load('../models/hostModel.js');
const { updateSettings } = await load('../models/settingsModel.js');
const {
computeNextRun,
createRunPlanSchedule,
listDueRunPlanSchedules,
listRunPlanScheduleRuns,
previewScheduleOccurrences
} = await load('../models/runPlanScheduleModel.js');
const { dispatchDueRunPlanSchedules } = await load('../services/runPlanScheduleWorker.js');
const owner = makeUser({ email: 'schedule-owner@test.local' });
const ts = new Date().toISOString();
function createRunnableRunPlan() {
const scriptId = 'scr_schedule_test';
db.prepare(`INSERT INTO scripts (id, name, content, visibility, owner_user_id, version, created_at, updated_at)
VALUES (?, 'Scheduled Script.ps1', 'Write-Output scheduled', 'personal', ?, 1, ?, ?)`).run(scriptId, owner, ts, ts);
const credential = createCredential({
name: 'Schedule Credential',
kind: 'username_password',
username: 'TEST\\svc',
secret: 'secret',
visibility: 'personal'
}, owner);
const host = createHost({
name: 'Schedule Host',
address: 'schedule.local',
transport: 'local',
osFamily: 'linux',
credentialId: credential.id,
visibility: 'personal'
}, owner);
const runplanId = 'run_schedule_test';
db.prepare(`INSERT INTO runplans (id, name, script_id, visibility, owner_user_id, parallel, created_at, updated_at)
VALUES (?, 'Scheduled RunPlan', ?, 'personal', ?, 1, ?, ?)`).run(runplanId, scriptId, owner, ts, ts);
db.prepare('INSERT INTO runplan_hosts (runplan_id, host_id) VALUES (?, ?)').run(runplanId, host.id);
return runplanId;
}
test('recurrence preview covers weekly and interval schedules', () => {
const weekly = {
scheduleType: 'weekly',
startAt: '2026-06-01T14:00:00.000Z',
timeOfDay: '09:30',
daysOfWeek: [1, 3]
};
const preview = previewScheduleOccurrences(weekly, 3, new Date('2026-06-02T00:00:00.000Z'));
assert.equal(preview.length, 3);
assert.ok(preview.every((date) => [1, 3].includes(new Date(date).getDay())));
const intervalNext = computeNextRun({
scheduleType: 'interval',
startAt: '2026-06-01T00:00:00.000Z',
intervalValue: 6,
intervalUnit: 'hours'
}, new Date('2026-06-01T07:00:00.000Z'));
assert.equal(intervalNext, '2026-06-01T12:00:00.000Z');
});
test('due RunPlan schedules dispatch jobs and record scheduler history', async () => {
updateSettings({ allow_script_execution: 'false' });
const runplanId = createRunnableRunPlan();
const schedule = createRunPlanSchedule({
name: 'Every minute schedule',
runplanId,
scheduleType: 'interval',
startAt: '2026-01-01T00:00:00.000Z',
intervalValue: 1,
intervalUnit: 'minutes',
visibility: 'personal'
}, owner);
db.prepare('UPDATE runplan_schedules SET next_run_at = ? WHERE id = ?').run('2026-01-01T00:02:00.000Z', schedule.id);
assert.ok(listDueRunPlanSchedules('2026-01-01T00:02:00.000Z').some((row) => row.id === schedule.id));
const summary = await dispatchDueRunPlanSchedules({ nowIso: '2026-01-01T00:02:00.000Z' });
assert.equal(summary.dispatched, 1);
const job = db.prepare('SELECT * FROM jobs WHERE runplan_id = ? ORDER BY created_at DESC').get(runplanId);
assert.ok(job);
const history = listRunPlanScheduleRuns(owner, false, 20);
assert.ok(history.some((event) => event.scheduleId === schedule.id && event.jobId === job.id));
updateSettings({ allow_script_execution: 'true' });
});

View File

@@ -2,8 +2,9 @@ import { db, load } from './setup.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
const { seed } = await load('../db.js');
const { getSetting, getBooleanSetting, getStringSetting, updateSettings, effectiveTrustedOrigins } = await load('../models/settingsModel.js');
const { resolvePowerShellExecutable, powerShellSpawnFailureLines } = await load('../services/powershellRunner.js');
const { resolvePowerShellExecutable, powerShellSignalFailureLines, powerShellSpawnFailureLines } = await load('../services/powershellRunner.js');
test('unpinned settings seed as editable defaults', () => {
// No SERVER_FQDN env var is set in the test environment, so server_fqdn is a
@@ -37,6 +38,24 @@ test('powershell_bin config is used by the runner at execution time', () => {
assert.equal(resolvePowerShellExecutable(), 'powershell.exe');
});
test('powershell_bin can be edited even when older databases marked it env-sourced', () => {
db.prepare('UPDATE settings SET value = ?, source = ? WHERE key = ?').run('pwsh', 'env', 'powershell_bin');
const result = updateSettings({ powershell_bin: 'powershell' });
assert.equal(result.powershell_bin.value, 'powershell');
assert.equal(result.powershell_bin.source, 'db');
assert.equal(resolvePowerShellExecutable(), 'powershell');
});
test('seed releases older env-sourced powershell_bin rows to the current default', () => {
db.prepare('UPDATE settings SET value = ?, source = ? WHERE key = ?').run('old-pwsh', 'env', 'powershell_bin');
seed();
const row = db.prepare('SELECT value, source FROM settings WHERE key = ?').get('powershell_bin');
assert.equal(row.value, 'pwsh');
assert.equal(row.source, 'default');
});
test('PowerShell spawn failures include operator guidance', () => {
const lines = powerShellSpawnFailureLines({ code: 'ENOENT', message: 'spawn pwsh ENOENT' }, 'pwsh');
assert.ok(lines.some((line) => line.includes('configured PowerShell executable "pwsh"')));
@@ -44,6 +63,13 @@ test('PowerShell spawn failures include operator guidance', () => {
assert.ok(lines.some((line) => line.includes('powershell.exe')));
});
test('PowerShell signal exits include crash diagnostics', () => {
const lines = powerShellSignalFailureLines('SIGABRT', 'pwsh');
assert.ok(lines.some((line) => line.includes('terminated by signal SIGABRT')));
assert.ok(lines.some((line) => line.includes('PowerShell/.NET process aborted')));
assert.ok(lines.some((line) => line.includes('pwsh -NoProfile')));
});
test('effectiveTrustedOrigins merges admin edits with built-in defaults', () => {
updateSettings({ trusted_origins: 'https://ops.example.com' });
const origins = effectiveTrustedOrigins();

View File

@@ -15,6 +15,7 @@ const {
normalizeBaseUrl,
normalizeVCenterVm,
previewVCenterHosts,
retrievePropertiesBody,
settingsFromVCenterConnection,
vmMatchesFilter
} = {
@@ -155,8 +156,22 @@ test('standalone VMware host connections use the Web Services profile', async ()
assert.equal(preview.candidates[0].powerState, 'POWERED_ON');
});
test('standalone VMware RetrievePropertiesEx SOAP closes boolean fields before paths', () => {
const body = retrievePropertiesBody(
{ type: 'PropertyCollector', value: 'ha-property-collector' },
{ type: 'ContainerView', value: 'session-vm-view' },
5
);
assert.match(body, /<all>false<\/all><pathSet>name<\/pathSet>/);
assert.match(body, /<skip>true<\/skip><selectSet/);
assert.match(body, /<skip>false<\/skip><\/selectSet>/);
assert.doesNotMatch(body, /<all>false><pathSet/);
assert.doesNotMatch(body, /<skip>false><\/selectSet>/);
});
test('standalone VMware VM candidates build managed host payloads', async () => {
const { restore } = installStandaloneEsxiSoapStub();
const { calls, restore } = installStandaloneEsxiSoapStub();
const connection = {
id: 'vc_host',
name: 'ESXi 01',
@@ -187,6 +202,10 @@ test('standalone VMware VM candidates build managed host payloads', async () =>
assert.ok(payload.tags.includes('esxi-vm:vim.VirtualMachine:101'));
assert.equal(power.checked, true);
assert.equal(power.state, 'POWERED_ON');
const powerBody = calls.filter((call) => call.body.includes('<RetrievePropertiesEx')).at(-1)?.body || '';
assert.match(powerBody, /<all>false<\/all><pathSet>runtime\.powerState<\/pathSet>/);
assert.match(powerBody, /<skip>false<\/skip><\/objectSet>/);
assert.doesNotMatch(powerBody, /<all>false><pathSet/);
});
test('standalone VMware SOAP retries alternate endpoint paths after /sdk 404', async () => {