160 lines
10 KiB
JavaScript
160 lines
10 KiB
JavaScript
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'));
|
|
});
|