diff --git a/README.md b/README.md index 355a3f08..87a7bd74 100644 --- a/README.md +++ b/README.md @@ -112,14 +112,16 @@ Scan a given SBOM for EOL data ``` USAGE - $ hd scan eol [--json] [-f | -d ] [-s] [--saveSbom] [--saveTrimmedSbom] [--hideReportUrl] [--version] + $ hd scan eol [--json] [-f | -d ] [-s] [-o ] [--saveSbom] [--sbomOutput ] [--saveTrimmedSbom] [--hideReportUrl] [--version] FLAGS -d, --dir= [default: ] The directory to scan in order to scan for EOL -f, --file= The file path of an existing SBOM to scan for EOL (supports CycloneDX and SPDX 2.3 formats) -s, --save Save the generated report as herodevs.report.json in the scanned directory + -o, --output= Save the generated report to a custom path (requires --save, defaults to herodevs.report.json when not provided) --hideReportUrl Hide the generated web report URL for this scan --saveSbom Save the generated SBOM as herodevs.sbom.json in the scanned directory + --sbomOutput= Save the generated SBOM to a custom path (requires --saveSbom, defaults to herodevs.sbom.json when not provided) --saveTrimmedSbom Save the trimmed SBOM as herodevs.sbom-trimmed.json in the scanned directory --version Show CLI version. @@ -146,6 +148,10 @@ EXAMPLES $ hd scan eol --save --saveSbom + Save the report and SBOM to custom paths + + $ hd scan eol --dir . --save --saveSbom --output ./reports/my-report.json --sbomOutput ./reports/my-sbom.json + Output the report in JSON format (for APIs, CI, etc.) $ hd scan eol --json diff --git a/e2e/scan/eol.test.ts b/e2e/scan/eol.test.ts index 6596004a..69c97e00 100644 --- a/e2e/scan/eol.test.ts +++ b/e2e/scan/eol.test.ts @@ -1,8 +1,10 @@ import { doesNotThrow } from 'node:assert'; -import { doesNotMatch, match, notStrictEqual, strictEqual } from 'node:assert/strict'; +import { doesNotMatch, match, notStrictEqual, ok, strictEqual } from 'node:assert/strict'; import { exec } from 'node:child_process'; -import { existsSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'; +import { randomUUID } from 'node:crypto'; +import { existsSync, readFileSync, rmSync, unlinkSync, writeFileSync } from 'node:fs'; import { mkdir } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; import path from 'node:path'; import { afterEach, beforeEach, describe, it } from 'node:test'; import { promisify } from 'node:util'; @@ -181,6 +183,43 @@ describe('scan:eol e2e', () => { unlinkSync(reportPath); }); + it('warns and skips saving when --output is provided without --save', async () => { + const customDir = path.join(tmpdir(), 'scan-eol-report-output', randomUUID()); + const customPath = path.join(customDir, 'custom-report.json'); + + const cmd = `scan:eol --dir ${simpleDir} --output ${customPath}`; + const { stderr } = await run(cmd); + + const reportExists = existsSync(customPath); + strictEqual(reportExists, false, 'Custom report file should not be created without --save'); + + match(stderr, /--output requires --save to write the report/i, 'Should warn that --output needs --save'); + + if (existsSync(customDir)) { + rmSync(customDir, { recursive: true, force: true }); + } + }); + + it('saves report to a custom path when --save and --output are provided', async () => { + const customDir = path.join(fixturesDir, 'outputs-save'); + const customPath = path.join(customDir, 'custom-report.json'); + await mkdir(customDir, { recursive: true }); + + const cmd = `scan:eol --dir ${simpleDir} --save --output ${customPath}`; + const { stderr } = await run(cmd); + + const reportExists = existsSync(customPath); + strictEqual(reportExists, true, 'Custom report file should be created when --save is provided'); + + doesNotMatch(stderr, /--output requires --save to write the report/i, 'Should not warn when --save is provided'); + + const reportJson = JSON.parse(readFileSync(customPath, 'utf-8')); + ok(Array.isArray(reportJson.components), 'Report should have components array'); + + unlinkSync(customPath); + rmSync(customDir, { recursive: true, force: true }); + }); + it('outputs JSON only when using the --json flag', async () => { const cmd = `scan:eol --file ${simpleSbom} --json`; const { stdout } = await run(cmd); @@ -236,6 +275,50 @@ describe('scan:eol e2e', () => { unlinkSync(sbomPath); }); + it('warns and skips saving when --sbomOutput is provided without --saveSbom', async () => { + const customDir = path.join(fixturesDir, 'sbom-outputs'); + const customPath = path.join(customDir, 'custom-sbom.json'); + await mkdir(customDir, { recursive: true }); + + const cmd = `scan:eol --dir ${simpleDir} --sbomOutput ${customPath}`; + const { stderr } = await run(cmd); + + const sbomExists = existsSync(customPath); + strictEqual(sbomExists, false, 'Custom SBOM file should not be created without --saveSbom'); + + match( + stderr, + /--sbomOutput requires --saveSbom to write the SBOM/i, + 'Should warn that --sbomOutput needs --saveSbom', + ); + + rmSync(customDir, { recursive: true, force: true }); + }); + + it('saves SBOM to a custom path when --sbomOutput is provided', async () => { + const customDir = path.join(fixturesDir, 'sbom-outputs'); + const customPath = path.join(customDir, 'custom-sbom.json'); + await mkdir(customDir, { recursive: true }); + + const cmd = `scan:eol --dir ${simpleDir} --saveSbom --sbomOutput ${customPath}`; + const { stderr } = await run(cmd); + + const sbomExists = existsSync(customPath); + strictEqual(sbomExists, true, 'Custom SBOM file should be created'); + + doesNotMatch( + stderr, + /--sbomOutput requires --saveSbom to write the SBOM/i, + 'Should not warn when --saveSbom is provided', + ); + + const sbomJson = JSON.parse(readFileSync(customPath, 'utf-8')); + strictEqual(sbomJson.bomFormat, 'CycloneDX', 'SBOM should be CycloneDX format'); + + unlinkSync(customPath); + rmSync(customDir, { recursive: true, force: true }); + }); + it('saves both report and SBOM when both --save and --saveSbom flags are used', async () => { const reportPath = path.join(simpleDir, `${filenamePrefix}.report.json`); const sbomPath = path.join(simpleDir, `${filenamePrefix}.sbom.json`); @@ -342,6 +425,33 @@ describe('scan:eol e2e', () => { doesNotMatch(stdout, /View your full EOL report/, 'Should not show web report text when hidden'); match(stdout, /To save your detailed JSON report, use the --save flag/, 'Should show save hint message'); }); + + it('omits save hint when --hideReportUrl is paired with custom outputs', async () => { + const customDir = path.join(fixturesDir, 'hide-report-output'); + const customPath = path.join(customDir, 'custom-report.json'); + await mkdir(customDir, { recursive: true }); + const cmd = `scan:eol --file ${simpleSbom} --hideReportUrl --save --output ${customPath}`; + const { stdout, stderr } = await run(cmd); + + doesNotMatch( + stdout, + /To save your detailed JSON report, use the --save flag/, + 'Should not show save hint when custom outputs are provided', + ); + + doesNotMatch( + stderr, + /Warning: --output requires --save to write the report/i, + 'Should not warn when --save is provided', + ); + + strictEqual(existsSync(customPath), true, 'Custom report file should be created'); + + if (existsSync(customPath)) { + unlinkSync(customPath); + } + rmSync(customDir, { recursive: true, force: true }); + }); }); describe('privacy and transparency', () => { @@ -415,18 +525,17 @@ describe('scan:eol e2e', () => { return output; } - function expectAny(output: { stdout: string; stderr: string; error?: Error }, patterns: RegExp[], message: string) { - const text = `${output.stderr}\n${output.stdout}\n${output.error?.message || ''}`; - const matched = patterns.some((re) => re.test(text)); - strictEqual(matched, true, message); + function combinedOutputText(output: { stdout: string; stderr: string; error?: { message?: unknown } }) { + const errorText = typeof output?.error?.message === 'string' ? output.error.message : ''; + return `${output.stderr}\n${output.stdout}\n${errorText}`; } it('fails when SBOM file does not exist', async () => { const missing = path.join(fixturesDir, 'npm', 'does-not-exist.json'); const out = await runExpectFail(`scan:eol --file ${missing}`); - expectAny( - out, - [/SBOM file not found:/i, /Failed to read SBOM file/i, /Failed to load SBOM file/i, /Loading SBOM file/i], + match( + combinedOutputText(out), + /(SBOM file not found:|Failed to read SBOM file|Failed to load SBOM file|Loading SBOM file)/i, 'Should indicate missing SBOM file', ); }); @@ -436,9 +545,9 @@ describe('scan:eol e2e', () => { writeFileSync(badFile, '{not-json'); try { const out = await runExpectFail(`scan:eol --file ${badFile}`); - expectAny( - out, - [/Failed to read SBOM file/i, /Failed to load SBOM file/i, /Loading SBOM file/i], + match( + combinedOutputText(out), + /(Failed to read SBOM file|Failed to load SBOM file|Loading SBOM file)/i, 'Should indicate invalid SBOM', ); } finally { @@ -451,9 +560,9 @@ describe('scan:eol e2e', () => { writeFileSync(badFile, JSON.stringify({ invalid: 'format', notSpdx: true, notCdx: true })); try { const out = await runExpectFail(`scan:eol --file ${badFile}`); - expectAny( - out, - [/Failed to read SBOM file/i, /Invalid SBOM file format/i, /Expected SPDX 2\.3 or CycloneDX format./i], + match( + combinedOutputText(out), + /(Failed to read SBOM file|Invalid SBOM file format|Expected SPDX 2\.3 or CycloneDX format\.)/i, 'Should indicate invalid SBOM format', ); } finally { @@ -464,18 +573,18 @@ describe('scan:eol e2e', () => { it('fails when directory does not exist', async () => { const missingDir = path.join(fixturesDir, 'npm', 'no-such-dir'); const out = await runExpectFail(`scan:eol --dir ${missingDir}`); - expectAny( - out, - [/Directory not found:/i, /Failed to scan directory/i, /Generating SBOM/i], + match( + combinedOutputText(out), + /(Directory not found:|Failed to scan directory|Generating SBOM)/i, 'Should indicate missing directory', ); }); it('fails when provided path is not a directory', async () => { const out = await runExpectFail(`scan:eol --dir ${simpleSbom}`); - expectAny( - out, - [/Path is not a directory:/i, /Failed to scan directory/i, /Generating SBOM/i], + match( + combinedOutputText(out), + /(Path is not a directory:|Failed to scan directory|Generating SBOM)/i, 'Should indicate non-directory path', ); }); @@ -485,7 +594,11 @@ describe('scan:eol e2e', () => { fetchMock.restore(); fetchMock = new FetchMock().addGraphQL({ eol: { createReport: { success: false, id: null, totalRecords: 0 } } }); const out = await runExpectFail(`scan:eol --file ${simpleSbom}`); - expectAny(out, [/Failed to submit scan to NES/i, /Scanning failed/i], 'Should indicate NES submission failure'); + match( + combinedOutputText(out), + /(Failed to submit scan to NES|Scanning failed)/i, + 'Should indicate NES submission failure', + ); }); it('fails when NES returns GraphQL errors', async () => { @@ -494,7 +607,27 @@ describe('scan:eol e2e', () => { { message: 'Internal server error', path: ['eol', 'createReport'] }, ]); const out = await runExpectFail(`scan:eol --file ${simpleSbom}`); - expectAny(out, [/Failed to submit scan to NES/i, /Scanning failed/i], 'Should indicate GraphQL errors from NES'); + match( + combinedOutputText(out), + /(Failed to submit scan to NES|Scanning failed)/i, + 'Should indicate GraphQL errors from NES', + ); + }); + + it('shows a helpful error when report output directory is invalid', async () => { + const invalidPath = path.join(fixturesDir, 'missing-dir', 'custom-report.json'); + const out = await runExpectFail(`scan:eol --dir ${simpleDir} --save --output ${invalidPath}`); + match( + combinedOutputText(out), + /Unable to save custom-report\.json/i, + 'Should indicate report could not be saved', + ); + }); + + it('shows a helpful error when SBOM output directory is invalid', async () => { + const invalidPath = path.join(fixturesDir, 'missing-dir', 'custom-sbom.json'); + const out = await runExpectFail(`scan:eol --dir ${simpleDir} --saveSbom --sbomOutput ${invalidPath}`); + match(combinedOutputText(out), /Unable to save custom-sbom\.json/i, 'Should indicate SBOM could not be saved'); }); }); }); diff --git a/src/commands/scan/eol.ts b/src/commands/scan/eol.ts index 03b3b656..4d71f1c9 100644 --- a/src/commands/scan/eol.ts +++ b/src/commands/scan/eol.ts @@ -13,13 +13,7 @@ import { formatScanResults, formatWebReportUrl, } from '../../service/display.svc.ts'; -import { - readSbomFromFile, - saveReportToFile, - saveSbomToFile, - saveTrimmedSbomToFile, - validateDirectory, -} from '../../service/file.svc.ts'; +import { readSbomFromFile, saveArtifactToFile, validateDirectory } from '../../service/file.svc.ts'; import { getErrorMessage } from '../../service/log.svc.ts'; export default class ScanEol extends Command { @@ -59,11 +53,19 @@ export default class ScanEol extends Command { default: false, description: `Save the generated report as ${filenamePrefix}.report.json in the scanned directory`, }), + output: Flags.string({ + char: 'o', + description: `Save the generated report to a custom path (defaults to ${filenamePrefix}.report.json when not provided)`, + }), saveSbom: Flags.boolean({ aliases: ['save-sbom'], default: false, description: `Save the generated SBOM as ${filenamePrefix}.sbom.json in the scanned directory`, }), + sbomOutput: Flags.string({ + aliases: ['sbom-output'], + description: `Save the generated SBOM to a custom path (defaults to ${filenamePrefix}.sbom.json when not provided)`, + }), saveTrimmedSbom: Flags.boolean({ aliases: ['save-trimmed-sbom'], default: false, @@ -97,8 +99,22 @@ export default class ScanEol extends Command { })); } - if (flags.saveSbom && !flags.file) { - const sbomPath = this.saveSbom(flags.dir, sbom); + let reportOutputPath = flags.output; + let sbomOutputPath = flags.sbomOutput; + + if (flags.output && !flags.save) { + this.warn('--output requires --save to write the report. Run again with --save to create the file.'); + reportOutputPath = undefined; + } + + if (flags.sbomOutput && !flags.saveSbom) { + this.warn('--sbomOutput requires --saveSbom to write the SBOM. Run again with --saveSbom to create the file.'); + sbomOutputPath = undefined; + } + + const shouldSaveSbom = !flags.file && flags.saveSbom; + if (shouldSaveSbom) { + const sbomPath = this.saveSbom(flags.dir, sbom, sbomOutputPath); this.log(`SBOM saved to ${sbomPath}`); track('CLI SBOM Output Saved', (context) => ({ command: context.command, @@ -135,8 +151,9 @@ export default class ScanEol extends Command { web_report_hidden: flags.hideReportUrl, })); - if (flags.save) { - const reportPath = this.saveReport(scan, flags.dir); + const shouldSaveReport = flags.save; + if (shouldSaveReport) { + const reportPath = this.saveReport(scan, flags.dir, reportOutputPath); this.log(`Report saved to ${reportPath}`); track('CLI JSON Scan Output Saved', (context) => ({ command: context.command, @@ -146,7 +163,7 @@ export default class ScanEol extends Command { } if (!this.jsonEnabled()) { - this.displayResults(scan, flags.hideReportUrl); + this.displayResults(scan, flags.hideReportUrl, Boolean(reportOutputPath || sbomOutputPath)); } return scan; @@ -202,9 +219,9 @@ export default class ScanEol extends Command { } } - private saveReport(report: EolReport, dir: string): string { + private saveReport(report: EolReport, dir: string, outputPath?: string): string { try { - return saveReportToFile(dir, report); + return saveArtifactToFile(dir, { kind: 'report', payload: report, outputPath }); } catch (error) { const errorMessage = getErrorMessage(error); track('CLI Error Encountered', () => ({ error: errorMessage })); @@ -212,9 +229,9 @@ export default class ScanEol extends Command { } } - private saveSbom(dir: string, sbom: CdxBom): string { + private saveSbom(dir: string, sbom: CdxBom, outputPath?: string): string { try { - return saveSbomToFile(dir, sbom); + return saveArtifactToFile(dir, { kind: 'sbom', payload: sbom, outputPath }); } catch (error) { const errorMessage = getErrorMessage(error); track('CLI Error Encountered', () => ({ error: errorMessage })); @@ -224,7 +241,7 @@ export default class ScanEol extends Command { private saveTrimmedSbom(dir: string, sbom: CdxBom): string { try { - return saveTrimmedSbomToFile(dir, sbom); + return saveArtifactToFile(dir, { kind: 'sbomTrimmed', payload: sbom }); } catch (error) { const errorMessage = getErrorMessage(error); track('CLI Error Encountered', () => ({ error: errorMessage })); @@ -232,7 +249,7 @@ export default class ScanEol extends Command { } } - private displayResults(report: EolReport, hideReportUrl: boolean): void { + private displayResults(report: EolReport, hideReportUrl: boolean, hasCustomOutput: boolean): void { const lines = formatScanResults(report); for (const line of lines) { this.log(line); @@ -243,7 +260,7 @@ export default class ScanEol extends Command { for (const line of lines) { this.log(line); } - } else if (hideReportUrl) { + } else if (hideReportUrl && !hasCustomOutput) { const lines = formatReportSaveHint(); for (const line of lines) { this.log(line); diff --git a/src/hooks/finally/finally.ts b/src/hooks/finally/finally.ts index c769ac6a..70d0576a 100644 --- a/src/hooks/finally/finally.ts +++ b/src/hooks/finally/finally.ts @@ -4,20 +4,20 @@ import { track } from '../../service/analytics.svc.ts'; const hook: Hook<'finally'> = async (opts) => { const isHelpOrVersionCmd = opts.argv.includes('--help') || opts.argv.includes('--version'); + const hasError = Boolean(opts.error); let spinner: Ora | undefined; - if (!isHelpOrVersionCmd) { + if (!isHelpOrVersionCmd && !hasError) { spinner = ora().start('Cleaning up'); } - const event = track('CLI Session Ended', (context) => ({ + await track('CLI Session Ended', (context) => ({ cli_version: context.cli_version, ended_at: new Date(), })).promise; - if (!isHelpOrVersionCmd) { - await event; + if (!isHelpOrVersionCmd && !hasError) { spinner?.stop(); } }; diff --git a/src/service/file.svc.ts b/src/service/file.svc.ts index a257a5c3..4ab2d582 100644 --- a/src/service/file.svc.ts +++ b/src/service/file.svc.ts @@ -9,6 +9,81 @@ export interface FileError extends Error { code?: string; } +/** + * Computes an absolute output path using either a provided path or the base directory and default name. + */ +function resolveOutputPath( + baseDir: string, + defaultFilename: string, + customPath?: string, +): { fileName: string; fullPath: string } { + const defaultOutput = resolve(join(baseDir, defaultFilename)); + + if (!customPath) { + return { fileName: defaultFilename, fullPath: defaultOutput }; + } + + const resolvedCustomPath = resolve(customPath); + let targetPath = resolvedCustomPath; + + const hasTrailingSeparator = /[\\/]$/.test(customPath); + const customIsDirectory = fs.existsSync(resolvedCustomPath) && fs.statSync(resolvedCustomPath).isDirectory(); + + if (hasTrailingSeparator || customIsDirectory) { + targetPath = join(resolvedCustomPath, defaultFilename); + } + + return { fileName: path.basename(targetPath), fullPath: targetPath }; +} + +/** + * Ensures the output directory for a given path exists, is a directory, and is writable. + */ +function ensureOutputDirectory(fullPath: string, fileName: string): void { + const targetDir = path.dirname(fullPath); + + if (!fs.existsSync(targetDir)) { + throw new Error(`Unable to save ${fileName}`); + } + + const stats = fs.statSync(targetDir); + if (!stats.isDirectory()) { + throw new Error(`Unable to save ${fileName}`); + } + + try { + fs.accessSync(targetDir, fs.constants.W_OK); + } catch { + throw new Error(`Unable to save ${fileName}`); + } +} + +/** + * Writes JSON to disk after validating directory constraints and formats the payload for readability. + */ +function writeJsonFile(fullPath: string, fileName: string, payload: unknown, failureLabel: string): string { + ensureOutputDirectory(fullPath, fileName); + + try { + fs.writeFileSync(fullPath, JSON.stringify(payload, null, 2)); + return fullPath; + } catch (error) { + const fileError = error as FileError; + + switch (fileError.code) { + case 'EACCES': + throw new Error(`Permission denied. Unable to save ${fileName}`); + case 'ENOSPC': + throw new Error(`No space left on device. Unable to save ${fileName}`); + case 'ENOENT': + case 'ENOTDIR': + throw new Error(`Unable to save ${fileName}`); + } + + throw new Error(`Failed to save ${failureLabel}: ${getErrorMessage(error)}`); + } +} + /** * Reads an SBOM from a file path and converts it to CycloneDX format * Supports both SPDX 2.3 and CycloneDX formats @@ -54,52 +129,26 @@ export function validateDirectory(dirPath: string): void { } } -/** - * Saves an SBOM to a file in the specified directory - */ -export function saveSbomToFile(dir: string, sbom: CdxBom): string { - const outputPath = join(dir, `${filenamePrefix}.sbom.json`); +type SaveArtifactKind = 'sbom' | 'sbomTrimmed' | 'report'; - try { - fs.writeFileSync(outputPath, JSON.stringify(sbom, null, 2)); - return outputPath; - } catch (error) { - throw new Error(`Failed to save SBOM: ${getErrorMessage(error)}`); - } -} +type SaveArtifactRequest = + | { kind: 'sbom'; payload: CdxBom; outputPath?: string } + | { kind: 'sbomTrimmed'; payload: CdxBom } + | { kind: 'report'; payload: EolReport; outputPath?: string }; -/** - * Saves a trimmed SBOM to a file in the specified directory - */ -export function saveTrimmedSbomToFile(dir: string, sbom: CdxBom): string { - const outputPath = join(dir, `${filenamePrefix}.sbom-trimmed.json`); - - try { - fs.writeFileSync(outputPath, JSON.stringify(sbom, null, 2)); - return outputPath; - } catch (error) { - throw new Error(`Failed to save trimmed SBOM: ${getErrorMessage(error)}`); - } -} +const artifactFilenames: Record = { + sbom: `${filenamePrefix}.sbom.json`, + sbomTrimmed: `${filenamePrefix}.sbom-trimmed.json`, + report: `${filenamePrefix}.report.json`, +}; /** - * Saves an EOL report to a file in the specified directory + * Saves an SBOM, trimmed SBOM, or report to disk using the correct default filename. */ -export function saveReportToFile(dir: string, report: EolReport): string { - const reportPath = path.join(dir, `${filenamePrefix}.report.json`); +export function saveArtifactToFile(dir: string, request: SaveArtifactRequest): string { + const defaultFilename = artifactFilenames[request.kind]; + const customOutputPath = 'outputPath' in request ? request.outputPath : undefined; + const { fileName, fullPath } = resolveOutputPath(dir, defaultFilename, customOutputPath); - try { - fs.writeFileSync(reportPath, JSON.stringify(report, null, 2)); - return reportPath; - } catch (error) { - const fileError = error as FileError; - - if (fileError.code === 'EACCES') { - throw new Error(`Permission denied. Unable to save report to ${filenamePrefix}.report.json`); - } - if (fileError.code === 'ENOSPC') { - throw new Error(`No space left on device. Unable to save report to ${filenamePrefix}.report.json`); - } - throw new Error(`Failed to save report: ${getErrorMessage(error)}`); - } + return writeJsonFile(fullPath, fileName, request.payload, fileName); } diff --git a/test/service/file.svc.test.ts b/test/service/file.svc.test.ts index 5dcdbf51..eaf2aa5b 100644 --- a/test/service/file.svc.test.ts +++ b/test/service/file.svc.test.ts @@ -1,21 +1,26 @@ import assert from 'node:assert'; import fs from 'node:fs'; -import { mkdtemp, writeFile } from 'node:fs/promises'; +import { mkdir, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { after, describe, it } from 'node:test'; import type { CdxBom, EolReport, SPDX23 } from '@herodevs/eol-shared'; -import { - readSbomFromFile, - saveReportToFile, - saveSbomToFile, - saveTrimmedSbomToFile, - validateDirectory, -} from '../../src/service/file.svc.ts'; +import { readSbomFromFile, saveArtifactToFile, validateDirectory } from '../../src/service/file.svc.ts'; describe('file.svc', () => { let tempDir: string; + const createTempDir = () => { + const prefix = join(tmpdir(), 'file-svc-test-'); + + if (typeof fs.mkdtempDisposableSync === 'function') { + const { path: dirPath } = fs.mkdtempDisposableSync(prefix); + return dirPath; + } + + return fs.mkdtempSync(prefix); + }; + const mockSbom: CdxBom = { bomFormat: 'CycloneDX', specVersion: '1.6', @@ -54,7 +59,7 @@ describe('file.svc', () => { describe('readSbomFromFile', () => { it('should read and parse a valid CycloneDX SBOM file', async () => { - tempDir = await mkdtemp(join(tmpdir(), 'file-svc-test-')); + tempDir = createTempDir(); const filePath = join(tempDir, 'test.json'); await writeFile(filePath, JSON.stringify(mockSbom)); @@ -63,7 +68,7 @@ describe('file.svc', () => { }); it('should read and convert a valid SPDX SBOM file to CycloneDX', async () => { - tempDir = await mkdtemp(join(tmpdir(), 'file-svc-test-')); + tempDir = createTempDir(); const filePath = join(tempDir, 'spdx-test.json'); await writeFile(filePath, JSON.stringify(mockSpdxSbom)); @@ -79,7 +84,7 @@ describe('file.svc', () => { }); it('should throw error for invalid JSON', async () => { - tempDir = await mkdtemp(join(tmpdir(), 'file-svc-test-')); + tempDir = createTempDir(); const filePath = join(tempDir, 'invalid.json'); await writeFile(filePath, 'invalid json'); @@ -87,7 +92,7 @@ describe('file.svc', () => { }); it('should throw error for invalid SBOM format (neither SPDX nor CycloneDX)', async () => { - tempDir = await mkdtemp(join(tmpdir(), 'file-svc-test-')); + tempDir = createTempDir(); const filePath = join(tempDir, 'invalid-format.json'); await writeFile(filePath, JSON.stringify({ invalid: 'format' })); @@ -100,7 +105,7 @@ describe('file.svc', () => { describe('validateDirectory', () => { it('should not throw for valid directory', async () => { - tempDir = await mkdtemp(join(tmpdir(), 'file-svc-test-')); + tempDir = createTempDir(); assert.doesNotThrow(() => validateDirectory(tempDir)); }); @@ -109,7 +114,7 @@ describe('file.svc', () => { }); it('should throw error for file instead of directory', async () => { - tempDir = await mkdtemp(join(tmpdir(), 'file-svc-test-')); + tempDir = createTempDir(); const filePath = join(tempDir, 'file.txt'); await writeFile(filePath, 'content'); @@ -117,11 +122,11 @@ describe('file.svc', () => { }); }); - describe('saveSbomToFile', () => { + describe('saveArtifactToFile', () => { it('should save SBOM to file successfully', async () => { - tempDir = await mkdtemp(join(tmpdir(), 'file-svc-test-')); + tempDir = createTempDir(); - const outputPath = saveSbomToFile(tempDir, mockSbom); + const outputPath = saveArtifactToFile(tempDir, { kind: 'sbom', payload: mockSbom }); assert.ok(fs.existsSync(outputPath)); const content = fs.readFileSync(outputPath, 'utf8'); @@ -129,21 +134,61 @@ describe('file.svc', () => { assert.deepStrictEqual(parsed, mockSbom); }); - it('should return the correct output path', async () => { - tempDir = await mkdtemp(join(tmpdir(), 'file-svc-test-')); + it('should return the correct SBOM output path', async () => { + tempDir = createTempDir(); - const outputPath = saveSbomToFile(tempDir, mockSbom); + const outputPath = saveArtifactToFile(tempDir, { kind: 'sbom', payload: mockSbom }); assert.ok(outputPath.endsWith('herodevs.sbom.json')); assert.ok(outputPath.includes(tempDir)); }); - }); - describe('saveTrimmedSbomToFile', () => { + it('should save SBOM to a custom path', async () => { + tempDir = createTempDir(); + const customDir = join(tempDir, 'nested'); + await mkdir(customDir); + + const customPath = join(customDir, 'custom-sbom.json'); + const outputPath = saveArtifactToFile(tempDir, { + kind: 'sbom', + payload: mockSbom, + outputPath: customPath, + }); + + assert.strictEqual(outputPath, customPath); + assert.ok(fs.existsSync(customPath)); + }); + + it('should throw a descriptive error when the custom directory is missing for SBOM', async () => { + tempDir = createTempDir(); + const missingPath = join(tempDir, 'missing', 'custom-sbom.json'); + + assert.throws( + () => saveArtifactToFile(tempDir, { kind: 'sbom', payload: mockSbom, outputPath: missingPath }), + /Unable to save custom-sbom\.json/, + ); + }); + + it('should default to SBOM filename when directory path is provided', async () => { + tempDir = createTempDir(); + const customDir = join(tempDir, 'nested'); + await mkdir(customDir); + + const outputPath = saveArtifactToFile(tempDir, { + kind: 'sbom', + payload: mockSbom, + outputPath: customDir, + }); + + const expectedPath = join(customDir, 'herodevs.sbom.json'); + assert.strictEqual(outputPath, expectedPath); + assert.ok(fs.existsSync(expectedPath)); + }); + it('should save trimmed SBOM to file successfully', async () => { - tempDir = await mkdtemp(join(tmpdir(), 'file-svc-test-')); + tempDir = createTempDir(); - const outputPath = saveTrimmedSbomToFile(tempDir, mockSbom); + const outputPath = saveArtifactToFile(tempDir, { kind: 'sbomTrimmed', payload: mockSbom }); assert.ok(fs.existsSync(outputPath)); const content = fs.readFileSync(outputPath, 'utf8'); @@ -151,21 +196,19 @@ describe('file.svc', () => { assert.deepStrictEqual(parsed, mockSbom); }); - it('should return the correct output path', async () => { - tempDir = await mkdtemp(join(tmpdir(), 'file-svc-test-')); + it('should return the correct trimmed SBOM output path', async () => { + tempDir = createTempDir(); - const outputPath = saveTrimmedSbomToFile(tempDir, mockSbom); + const outputPath = saveArtifactToFile(tempDir, { kind: 'sbomTrimmed', payload: mockSbom }); assert.ok(outputPath.endsWith('herodevs.sbom-trimmed.json')); assert.ok(outputPath.includes(tempDir)); }); - }); - describe('saveReportToFile', () => { it('should save report to file successfully', async () => { - tempDir = await mkdtemp(join(tmpdir(), 'file-svc-test-')); + tempDir = createTempDir(); - const outputPath = saveReportToFile(tempDir, mockReport); + const outputPath = saveArtifactToFile(tempDir, { kind: 'report', payload: mockReport }); assert.ok(fs.existsSync(outputPath)); const content = fs.readFileSync(outputPath, 'utf8'); @@ -173,13 +216,55 @@ describe('file.svc', () => { assert.deepStrictEqual(parsed, mockReport); }); - it('should return the correct output path', async () => { - tempDir = await mkdtemp(join(tmpdir(), 'file-svc-test-')); + it('should return the correct report output path', async () => { + tempDir = createTempDir(); - const outputPath = saveReportToFile(tempDir, mockReport); + const outputPath = saveArtifactToFile(tempDir, { kind: 'report', payload: mockReport }); assert.ok(outputPath.endsWith('herodevs.report.json')); assert.ok(outputPath.includes(tempDir)); }); + + it('should save report to a custom path', async () => { + tempDir = createTempDir(); + const customDir = join(tempDir, 'nested'); + await mkdir(customDir); + + const customPath = join(customDir, 'my-report.json'); + const outputPath = saveArtifactToFile(tempDir, { + kind: 'report', + payload: mockReport, + outputPath: customPath, + }); + + assert.strictEqual(outputPath, customPath); + assert.ok(fs.existsSync(customPath)); + }); + + it('should throw a descriptive error when the custom directory is missing for report', async () => { + tempDir = createTempDir(); + const missingPath = join(tempDir, 'missing', 'my-report.json'); + + assert.throws( + () => saveArtifactToFile(tempDir, { kind: 'report', payload: mockReport, outputPath: missingPath }), + /Unable to save my-report\.json/, + ); + }); + + it('should default to report filename when directory path is provided', async () => { + tempDir = createTempDir(); + const customDir = join(tempDir, 'reports'); + await mkdir(customDir); + + const outputPath = saveArtifactToFile(tempDir, { + kind: 'report', + payload: mockReport, + outputPath: customDir, + }); + + const expectedPath = join(customDir, 'herodevs.report.json'); + assert.strictEqual(outputPath, expectedPath); + assert.ok(fs.existsSync(expectedPath)); + }); }); });