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'));
});