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