diff --git a/package-lock.json b/package-lock.json index 5cf2118..8534120 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,12 +10,13 @@ "license": "AGPL-3.0-only", "dependencies": { "@apidevtools/json-schema-ref-parser": "^15.1.3", + "adm-zip": "^0.5.16", "ajv": "^8.17.1", "axios": "^1.13.2", - "doc-detective-common": "^3.6.0", + "doc-detective-common": "3.6.0-dev.1", "dotenv": "^17.2.3", "json-schema-faker": "^0.5.9", - "posthog-node": "^5.17.0" + "posthog-node": "^5.17.2" }, "devDependencies": { "body-parser": "^2.2.1", @@ -124,9 +125,9 @@ } }, "node_modules/@posthog/core": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.7.0.tgz", - "integrity": "sha512-d6ZV4grpzeH/6/LP8quMVpSjY1puRkrqfwcPvGRKUAX7tb7YHyp/zMiTDuJmOFbpUxAMBXH5nDwcPiyCY2WGzA==", + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.7.1.tgz", + "integrity": "sha512-kjK0eFMIpKo9GXIbts8VtAknsoZ18oZorANdtuTj1CbgS28t4ZVq//HAWhnxEuXRTrtkd+SUJ6Ux3j2Af8NCuA==", "license": "MIT", "dependencies": { "cross-spawn": "^7.0.6" @@ -217,11 +218,21 @@ "node": ">= 0.6" } }, + "node_modules/adm-zip": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.16.tgz", + "integrity": "sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==", + "license": "MIT", + "engines": { + "node": ">=12.0" + } + }, "node_modules/ajv": { "version": "8.17.1", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -702,9 +713,9 @@ } }, "node_modules/doc-detective-common": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/doc-detective-common/-/doc-detective-common-3.6.0.tgz", - "integrity": "sha512-rHZ3WNuw3Y51KduBh9GgnrYFzJdbCQHPP3xnkp+W8QyF+4bKhWFlVVmbotFh/XL3BupbV9+CP7YEBTsW02Hvuw==", + "version": "3.6.0-dev.1", + "resolved": "https://registry.npmjs.org/doc-detective-common/-/doc-detective-common-3.6.0-dev.1.tgz", + "integrity": "sha512-e+3FNyqjhPUZRq+4A1t7G+au07RZockzCHdQ6LDaQQySPtAiNSO42v48ylbHIu4ZOn06SO933rVJe/b+e1GVdw==", "license": "AGPL-3.0-only", "dependencies": { "@apidevtools/json-schema-ref-parser": "^15.1.3", @@ -1372,6 +1383,7 @@ "resolved": "https://registry.npmjs.org/jsep/-/jsep-1.4.0.tgz", "integrity": "sha512-B7qPcEVE3NVkmSJbaYxvv4cHkVW7DQsZz13pUMrfS8z8Q/BuShN+gcTXrUlPiGqM2/t/EEaI030bpxMqY8gMlw==", "license": "MIT", + "peer": true, "engines": { "node": ">= 10.16.0" } @@ -1789,12 +1801,12 @@ "license": "ISC" }, "node_modules/posthog-node": { - "version": "5.17.0", - "resolved": "https://registry.npmjs.org/posthog-node/-/posthog-node-5.17.0.tgz", - "integrity": "sha512-M+ftj0kLJk6wVF1xW5cStSany0LBC6YDVO7RPma2poo+PrpeiTk+ovhqcIqWAySDdTcBHJfBV9aIFYWPl2y6kg==", + "version": "5.17.2", + "resolved": "https://registry.npmjs.org/posthog-node/-/posthog-node-5.17.2.tgz", + "integrity": "sha512-lz3YJOr0Nmiz0yHASaINEDHqoV+0bC3eD8aZAG+Ky292dAnVYul+ga/dMX8KCBXg8hHfKdxw0SztYD5j6dgUqQ==", "license": "MIT", "dependencies": { - "@posthog/core": "1.7.0" + "@posthog/core": "1.7.1" }, "engines": { "node": ">=20" diff --git a/package.json b/package.json index da39660..eb1b067 100644 --- a/package.json +++ b/package.json @@ -25,12 +25,13 @@ "homepage": "https://github.com/doc-detective/doc-detective-core#readme", "dependencies": { "@apidevtools/json-schema-ref-parser": "^15.1.3", + "adm-zip": "^0.5.16", "ajv": "^8.17.1", "axios": "^1.13.2", - "doc-detective-common": "^3.6.0", + "doc-detective-common": "3.6.0-dev.1", "dotenv": "^17.2.3", "json-schema-faker": "^0.5.9", - "posthog-node": "^5.17.0" + "posthog-node": "^5.17.2" }, "devDependencies": { "body-parser": "^2.2.1", diff --git a/src/heretto.js b/src/heretto.js new file mode 100644 index 0000000..c535e33 --- /dev/null +++ b/src/heretto.js @@ -0,0 +1,430 @@ +const axios = require("axios"); +const fs = require("fs"); +const path = require("path"); +const os = require("os"); +const crypto = require("crypto"); +const AdmZip = require("adm-zip"); + +// Internal constants - not exposed to users +const POLLING_INTERVAL_MS = 5000; +const POLLING_TIMEOUT_MS = 300000; // 5 minutes +const API_REQUEST_TIMEOUT_MS = 30000; // 30 seconds for individual API requests +const DOWNLOAD_TIMEOUT_MS = 300000; // 5 minutes for downloads +const DEFAULT_SCENARIO_NAME = "Doc Detective"; + +/** + * Creates a Base64-encoded Basic Auth header from username and API token. + * @param {string} username - Heretto CCMS username (email) + * @param {string} apiToken - API token generated in Heretto CCMS + * @returns {string} Base64-encoded authorization header value + */ +function createAuthHeader(username, apiToken) { + const credentials = `${username}:${apiToken}`; + return Buffer.from(credentials).toString("base64"); +} + +/** + * Builds the base URL for Heretto CCMS API. + * @param {string} organizationId - The organization subdomain + * @returns {string} Base API URL + */ +function getBaseUrl(organizationId) { + return `https://${organizationId}.heretto.com/ezdnxtgen/api/v2`; +} + +/** + * Creates an axios instance configured for Heretto API requests. + * @param {Object} herettoConfig - Heretto integration configuration + * @returns {Object} Configured axios instance + */ +function createApiClient(herettoConfig) { + const authHeader = createAuthHeader( + herettoConfig.username, + herettoConfig.apiToken + ); + return axios.create({ + baseURL: getBaseUrl(herettoConfig.organizationId), + timeout: API_REQUEST_TIMEOUT_MS, + headers: { + Authorization: `Basic ${authHeader}`, + "Content-Type": "application/json", + }, + }); +} + +/** + * Fetches all available publishing scenarios from Heretto. + * @param {Object} client - Configured axios instance + * @returns {Promise} Array of publishing scenarios + */ +async function getPublishingScenarios(client) { + const response = await client.get("/publishes/scenarios"); + return response.data.content || []; +} + +/** + * Fetches parameters for a specific publishing scenario. + * @param {Object} client - Configured axios instance + * @param {string} scenarioId - ID of the publishing scenario + * @returns {Promise} Scenario parameters object + */ +async function getPublishingScenarioParameters(client, scenarioId) { + const response = await client.get( + `/publishes/scenarios/${scenarioId}/parameters` + ); + return response.data; +} + +/** + * Finds an existing publishing scenario by name and validates its configuration. + * @param {Object} client - Configured axios instance + * @param {Function} log - Logging function + * @param {Object} config - Doc Detective config for logging + * @param {string} scenarioName - Name of the scenario to find + * @returns {Promise} Object with scenarioId and fileId, or null if not found or invalid + */ +async function findScenario(client, log, config, scenarioName) { + try { + const scenarios = await getPublishingScenarios(client); + const foundScenario = scenarios.find((s) => s.name === scenarioName); + + if (!foundScenario) { + log(config, "error", `No existing "${scenarioName}" scenario found.`); + return null; + } + + const scenarioParameters = await getPublishingScenarioParameters( + client, + foundScenario.id + ); + + if (!scenarioParameters) { + log( + config, + "error", + `Failed to retrieve scenario details for ID: ${foundScenario.id}` + ); + return null; + } + + // Make sure that scenarioParameters.content has an object with name="transtype" and options[0].value="dita" + const transtypeParam = scenarioParameters.content.find( + (param) => param.name === "transtype" + ); + if (!transtypeParam || transtypeParam.value !== "dita") { + log( + config, + "error", + `Existing "${scenarioName}" scenario has incorrect "transtype" parameter settings. Make sure it is set to "dita".` + ); + return null; + } + + // Make sure that scenarioParameters.content has an object with name="tool-kit-name" and value="default/dita-ot-3.6.1" + const toolKitParam = scenarioParameters.content.find( + (param) => param.name === "tool-kit-name" + ); + if (!toolKitParam || !toolKitParam.value) { + log( + config, + "error", + `Existing "${scenarioName}" scenario has incorrect "tool-kit-name" parameter settings.` + ); + return null; + } + + // Make sure that scenarioParameters.content has an object with type="file_uuid_picker" and a value + const fileUuidPickerParam = scenarioParameters.content.find( + (param) => param.type === "file_uuid_picker" + ); + if (!fileUuidPickerParam || !fileUuidPickerParam.value) { + log( + config, + "error", + `Existing "${scenarioName}" scenario has incorrect "file_uuid_picker" parameter settings. Make sure it has a valid value.` + ); + return null; + } + + log( + config, + "debug", + `Found existing "${scenarioName}" scenario: ${foundScenario.id}` + ); + return { scenarioId: foundScenario.id, fileId: fileUuidPickerParam.value }; + } catch (error) { + log( + config, + "error", + `Failed to find publishing scenario: ${error.message}` + ); + return null; + } +} + +/** + * Triggers a publishing job for a DITA map. + * @param {Object} client - Configured axios instance + * @param {string} fileId - UUID of the DITA map + * @param {string} scenarioId - ID of the publishing scenario to use + * @returns {Promise} Publishing job object + */ +async function triggerPublishingJob(client, fileId, scenarioId) { + const response = await client.post(`/files/${fileId}/publishes`, { + scenario: scenarioId, + parameters: [] + }); + return response.data; +} + +/** + * Gets the status of a publishing job. + * @param {Object} client - Configured axios instance + * @param {string} fileId - UUID of the DITA map + * @param {string} jobId - ID of the publishing job + * @returns {Promise} Job status object + */ +async function getJobStatus(client, fileId, jobId) { + const response = await client.get( + `/files/${fileId}/publishes/${jobId}` + ); + return response.data; +} + +/** + * Polls a publishing job until completion or timeout. + * @param {Object} client - Configured axios instance + * @param {string} fileId - UUID of the DITA map + * @param {string} jobId - ID of the publishing job + * @param {Function} log - Logging function + * @param {Object} config - Doc Detective config for logging + * @returns {Promise} Completed job object or null on timeout/failure + */ +async function pollJobStatus(client, fileId, jobId, log, config) { + const startTime = Date.now(); + + while (Date.now() - startTime < POLLING_TIMEOUT_MS) { + try { + const job = await getJobStatus(client, fileId, jobId); + log(config, "debug", `Job ${jobId} status: ${job?.status?.status}`); + + if (job?.status?.result === "SUCCESS") { + return job; + } + + if (job?.status?.result === "FAIL") { + log( + config, + "warning", + `Publishing job ${jobId} failed.` + ); + return null; + } + + // Wait before next poll + await new Promise((resolve) => setTimeout(resolve, POLLING_INTERVAL_MS)); + } catch (error) { + log(config, "warning", `Error polling job status: ${error.message}`); + return null; + } + } + + log( + config, + "warning", + `Publishing job ${jobId} timed out after ${ + POLLING_TIMEOUT_MS / 1000 + } seconds` + ); + return null; +} + +/** + * Downloads the publishing job output and extracts it to temp directory. + * @param {Object} client - Configured axios instance + * @param {string} fileId - UUID of the DITA map + * @param {string} jobId - ID of the publishing job + * @param {string} herettoName - Name of the Heretto integration for directory naming + * @param {Function} log - Logging function + * @param {Object} config - Doc Detective config for logging + * @returns {Promise} Path to extracted content or null on failure + */ +async function downloadAndExtractOutput( + client, + fileId, + jobId, + herettoName, + log, + config +) { + try { + // Create temp directory if it doesn't exist + const tempDir = `${os.tmpdir()}/doc-detective`; + fs.mkdirSync(tempDir, { recursive: true }); + + // Create unique output directory based on heretto name and job ID + const hash = crypto + .createHash("md5") + .update(`${herettoName}_${jobId}`) + .digest("hex"); + const outputDir = path.join(tempDir, `heretto_${hash}`); + + // Download the output file + log( + config, + "debug", + `Downloading publishing job output for ${herettoName}...` + ); + const response = await client.get( + `/files/${fileId}/publishes/${jobId}/assets-all`, + { + responseType: "arraybuffer", + timeout: DOWNLOAD_TIMEOUT_MS, + headers: { + Accept: "application/octet-stream", + }, + } + ); + + // Save ZIP to temp file + const zipPath = path.join(tempDir, `heretto_${hash}.zip`); + fs.writeFileSync(zipPath, response.data); + + // Extract ZIP contents with path traversal protection + log(config, "debug", `Extracting output to ${outputDir}...`); + const zip = new AdmZip(zipPath); + const resolvedOutputDir = path.resolve(outputDir); + + // Validate and extract entries safely to prevent zip slip attacks + for (const entry of zip.getEntries()) { + const entryPath = path.join(outputDir, entry.entryName); + const resolvedPath = path.resolve(entryPath); + + // Ensure the resolved path is within outputDir + if (!resolvedPath.startsWith(resolvedOutputDir + path.sep) && resolvedPath !== resolvedOutputDir) { + log(config, "warning", `Skipping potentially malicious ZIP entry: ${entry.entryName}`); + continue; + } + + if (entry.isDirectory) { + fs.mkdirSync(resolvedPath, { recursive: true }); + } else { + fs.mkdirSync(path.dirname(resolvedPath), { recursive: true }); + fs.writeFileSync(resolvedPath, entry.getData()); + } + } + + // Clean up ZIP file + fs.unlinkSync(zipPath); + + log( + config, + "info", + `Heretto content "${herettoName}" extracted to ${outputDir}` + ); + return outputDir; + } catch (error) { + log( + config, + "warning", + `Failed to download or extract output: ${error.message}` + ); + return null; + } +} + +/** + * Main function to load content from a Heretto CMS instance. + * Triggers a publishing job, waits for completion, and downloads the output. + * @param {Object} herettoConfig - Heretto integration configuration + * @param {Function} log - Logging function + * @param {Object} config - Doc Detective config for logging + * @returns {Promise} Path to extracted content or null on failure + */ +async function loadHerettoContent(herettoConfig, log, config) { + log( + config, + "info", + `Loading content from Heretto "${herettoConfig.name}"...` + ); + + try { + const client = createApiClient(herettoConfig); + + // Find the Doc Detective publishing scenario + const scenarioName = herettoConfig.scenarioName || DEFAULT_SCENARIO_NAME; + const scenario = await findScenario(client, log, config, scenarioName); + if (!scenario) { + log( + config, + "warning", + `Skipping Heretto "${herettoConfig.name}" - could not find or create publishing scenario` + ); + return null; + } + + // Trigger publishing job + log( + config, + "debug", + `Triggering publishing job for file ${scenario.fileId}...` + ); + const job = await triggerPublishingJob( + client, + scenario.fileId, + scenario.scenarioId + ); + log(config, "debug", `Publishing job started: ${job.jobId}`); + + // Poll for completion + log(config, "info", `Waiting for publishing job to complete...`); + const completedJob = await pollJobStatus( + client, + scenario.fileId, + job.jobId, + log, + config + ); + if (!completedJob) { + log( + config, + "warning", + `Skipping Heretto "${herettoConfig.name}" - publishing job failed or timed out` + ); + return null; + } + + // Download and extract output + const outputPath = await downloadAndExtractOutput( + client, + scenario.fileId, + job.jobId, + herettoConfig.name, + log, + config + ); + + return outputPath; + } catch (error) { + log( + config, + "warning", + `Failed to load Heretto "${herettoConfig.name}": ${error.message}` + ); + return null; + } +} + +module.exports = { + createAuthHeader, + createApiClient, + findScenario, + triggerPublishingJob, + pollJobStatus, + downloadAndExtractOutput, + loadHerettoContent, + // Export constants for testing + POLLING_INTERVAL_MS, + POLLING_TIMEOUT_MS, + DEFAULT_SCENARIO_NAME, +}; diff --git a/src/heretto.test.js b/src/heretto.test.js new file mode 100644 index 0000000..c89ea82 --- /dev/null +++ b/src/heretto.test.js @@ -0,0 +1,483 @@ +const sinon = require("sinon"); +const proxyquire = require("proxyquire"); +const path = require("path"); +const os = require("os"); + +before(async function () { + const { expect } = await import("chai"); + global.expect = expect; +}); + +describe("Heretto Integration", function () { + let heretto; + let axiosCreateStub; + let mockClient; + + beforeEach(function () { + // Create mock axios client + mockClient = { + get: sinon.stub(), + post: sinon.stub(), + }; + + // Stub axios.create to return our mock client + axiosCreateStub = sinon.stub().returns(mockClient); + + // Use proxyquire to inject stubbed axios + heretto = proxyquire("../src/heretto", { + axios: { + create: axiosCreateStub, + }, + }); + }); + + afterEach(function () { + sinon.restore(); + }); + + describe("createAuthHeader", function () { + it("should create a Base64-encoded auth header", function () { + const authHeader = heretto.createAuthHeader("user@example.com", "token123"); + + // Base64 of "user@example.com:token123" + const expected = Buffer.from("user@example.com:token123").toString("base64"); + expect(authHeader).to.equal(expected); + }); + + it("should handle special characters in credentials", function () { + const authHeader = heretto.createAuthHeader("user@example.com", "p@ss:w0rd!"); + + const expected = Buffer.from("user@example.com:p@ss:w0rd!").toString("base64"); + expect(authHeader).to.equal(expected); + }); + }); + + describe("createApiClient", function () { + it("should create an axios client with correct config", function () { + const herettoConfig = { + organizationId: "thunderbird", + username: "user@example.com", + apiToken: "token123", + }; + + heretto.createApiClient(herettoConfig); + + expect(axiosCreateStub.calledOnce).to.be.true; + const createConfig = axiosCreateStub.firstCall.args[0]; + expect(createConfig.baseURL).to.equal("https://thunderbird.heretto.com/ezdnxtgen/api/v2"); + expect(createConfig.headers.Authorization).to.include("Basic "); + expect(createConfig.headers["Content-Type"]).to.equal("application/json"); + }); + }); + + describe("findScenario", function () { + const mockLog = sinon.stub(); + const mockConfig = { logLevel: "info" }; + + beforeEach(function () { + mockLog.reset(); + }); + + it("should return scenarioId and fileId when valid scenario is found", async function () { + const existingScenario = { + id: "scenario-123", + name: "Doc Detective", + }; + + const scenarioParameters = { + content: [ + { name: "transtype", value: "dita" }, + { name: "tool-kit-name", value: "default/dita-ot-3.6.1" }, + { type: "file_uuid_picker", value: "file-uuid-456" }, + ], + }; + + mockClient.get + .onFirstCall().resolves({ + data: { content: [existingScenario, { id: "other", name: "Other" }] }, + }) + .onSecondCall().resolves({ data: scenarioParameters }); + + const result = await heretto.findScenario(mockClient, mockLog, mockConfig, "Doc Detective"); + + expect(result).to.deep.equal({ scenarioId: "scenario-123", fileId: "file-uuid-456" }); + expect(mockClient.get.calledTwice).to.be.true; + expect(mockClient.post.called).to.be.false; + }); + + it("should return null if scenario is not found", async function () { + mockClient.get.resolves({ + data: { content: [{ id: "other", name: "Other Scenario" }] }, + }); + + const result = await heretto.findScenario(mockClient, mockLog, mockConfig, "Doc Detective"); + + expect(result).to.be.null; + expect(mockClient.get.calledOnce).to.be.true; + expect(mockClient.post.called).to.be.false; + }); + + it("should return null if scenario fetch fails", async function () { + mockClient.get.rejects(new Error("Network error")); + + const result = await heretto.findScenario(mockClient, mockLog, mockConfig, "Doc Detective"); + + expect(result).to.be.null; + }); + + it("should return null if transtype parameter is incorrect", async function () { + const existingScenario = { + id: "scenario-123", + name: "Doc Detective", + }; + + const scenarioParameters = { + content: [ + { name: "transtype", value: "html5" }, + { name: "tool-kit-name", value: "default/dita-ot-3.6.1" }, + { type: "file_uuid_picker", value: "file-uuid-456" }, + ], + }; + + mockClient.get + .onFirstCall().resolves({ + data: { content: [existingScenario] }, + }) + .onSecondCall().resolves({ data: scenarioParameters }); + + const result = await heretto.findScenario(mockClient, mockLog, mockConfig, "Doc Detective"); + + expect(result).to.be.null; + }); + + it("should return null if tool-kit-name parameter is missing", async function () { + const existingScenario = { + id: "scenario-123", + name: "Doc Detective", + }; + + const scenarioParameters = { + content: [ + { name: "transtype", value: "dita" }, + { type: "file_uuid_picker", value: "file-uuid-456" }, + ], + }; + + mockClient.get + .onFirstCall().resolves({ + data: { content: [existingScenario] }, + }) + .onSecondCall().resolves({ data: scenarioParameters }); + + const result = await heretto.findScenario(mockClient, mockLog, mockConfig, "Doc Detective"); + + expect(result).to.be.null; + }); + + it("should return null if file_uuid_picker parameter is missing", async function () { + const existingScenario = { + id: "scenario-123", + name: "Doc Detective", + }; + + const scenarioParameters = { + content: [ + { name: "transtype", value: "dita" }, + { name: "tool-kit-name", value: "default/dita-ot-3.6.1" }, + ], + }; + + mockClient.get + .onFirstCall().resolves({ + data: { content: [existingScenario] }, + }) + .onSecondCall().resolves({ data: scenarioParameters }); + + const result = await heretto.findScenario(mockClient, mockLog, mockConfig, "Doc Detective"); + + expect(result).to.be.null; + }); + }); + + describe("triggerPublishingJob", function () { + it("should trigger a publishing job", async function () { + const expectedJob = { + jobId: "job-123", + status: "PENDING", + }; + + mockClient.post.resolves({ data: expectedJob }); + + const result = await heretto.triggerPublishingJob(mockClient, "file-uuid", "scenario-id"); + + expect(result).to.deep.equal(expectedJob); + expect(mockClient.post.calledOnce).to.be.true; + expect(mockClient.post.firstCall.args[0]).to.equal("/files/file-uuid/publishes"); + expect(mockClient.post.firstCall.args[1]).to.deep.equal({ scenario: "scenario-id", parameters: [] }); + }); + + it("should throw error when job creation fails", async function () { + mockClient.post.rejects(new Error("API error")); + + try { + await heretto.triggerPublishingJob(mockClient, "file-uuid", "scenario-id"); + expect.fail("Expected error to be thrown"); + } catch (error) { + expect(error.message).to.equal("API error"); + } + }); + }); + + describe("pollJobStatus", function () { + const mockLog = sinon.stub(); + const mockConfig = { logLevel: "info" }; + + beforeEach(function () { + mockLog.reset(); + }); + + it("should return completed job when status.result is SUCCESS", async function () { + const completedJob = { + id: "job-123", + status: { status: "COMPLETED", result: "SUCCESS" }, + }; + + mockClient.get.resolves({ data: completedJob }); + + const result = await heretto.pollJobStatus(mockClient, "file-uuid", "job-123", mockLog, mockConfig); + + expect(result).to.deep.equal(completedJob); + }); + + it("should return null when status.result is FAIL", async function () { + const failedJob = { + id: "job-123", + status: { status: "FAILED", result: "FAIL" }, + }; + + mockClient.get.resolves({ data: failedJob }); + + const result = await heretto.pollJobStatus(mockClient, "file-uuid", "job-123", mockLog, mockConfig); + + expect(result).to.be.null; + }); + + it("should poll until completion", async function () { + // Use fake timers to avoid waiting for real POLLING_INTERVAL_MS delays + const clock = sinon.useFakeTimers(); + + mockClient.get + .onFirstCall().resolves({ data: { id: "job-123", status: { status: "PENDING", result: null } } }) + .onSecondCall().resolves({ data: { id: "job-123", status: { status: "PROCESSING", result: null } } }) + .onThirdCall().resolves({ data: { id: "job-123", status: { status: "COMPLETED", result: "SUCCESS" } } }); + + const pollPromise = heretto.pollJobStatus(mockClient, "file-uuid", "job-123", mockLog, mockConfig); + + // Advance time past the polling intervals + await clock.tickAsync(heretto.POLLING_INTERVAL_MS); + await clock.tickAsync(heretto.POLLING_INTERVAL_MS); + await clock.tickAsync(heretto.POLLING_INTERVAL_MS); + + const result = await pollPromise; + + expect(result.status.result).to.equal("SUCCESS"); + expect(mockClient.get.callCount).to.equal(3); + + clock.restore(); + }); + + it("should return null on timeout", async function () { + // Use fake timers to avoid waiting for real timeout + const clock = sinon.useFakeTimers(); + + // Always return PENDING status (never completes) + mockClient.get.resolves({ + data: { id: "job-123", status: { status: "PENDING", result: null } } + }); + + const pollPromise = heretto.pollJobStatus(mockClient, "file-uuid", "job-123", mockLog, mockConfig); + + // Advance past the timeout + await clock.tickAsync(heretto.POLLING_TIMEOUT_MS + heretto.POLLING_INTERVAL_MS); + + const result = await pollPromise; + expect(result).to.be.null; + + clock.restore(); + }); + + it("should return null when polling error occurs", async function () { + mockClient.get.rejects(new Error("Network error")); + + const result = await heretto.pollJobStatus(mockClient, "file-uuid", "job-123", mockLog, mockConfig); + + expect(result).to.be.null; + }); + }); + + describe("loadHerettoContent", function () { + const mockLog = sinon.stub(); + const mockConfig = { logLevel: "info" }; + + beforeEach(function () { + mockLog.reset(); + }); + + it("should return null if scenario lookup fails", async function () { + const herettoConfig = { + name: "test-heretto", + organizationId: "test-org", + username: "user@example.com", + apiToken: "token123", + scenarioName: "Doc Detective", + }; + + // Scenario fetch fails + mockClient.get.rejects(new Error("Network error")); + + const result = await heretto.loadHerettoContent(herettoConfig, mockLog, mockConfig); + + expect(result).to.be.null; + }); + + it("should return null if publishing job creation fails", async function () { + const herettoConfig = { + name: "test-heretto", + organizationId: "test-org", + username: "user@example.com", + apiToken: "token123", + scenarioName: "Doc Detective", + }; + + const scenarioParameters = { + content: [ + { name: "transtype", value: "dita" }, + { name: "tool-kit-name", value: "default/dita-ot-3.6.1" }, + { type: "file_uuid_picker", value: "file-uuid-456" }, + ], + }; + + // Scenario exists with valid parameters + mockClient.get + .onFirstCall().resolves({ + data: { content: [{ id: "scenario-123", name: "Doc Detective" }] }, + }) + .onSecondCall().resolves({ data: scenarioParameters }); + + // Job creation fails + mockClient.post.rejects(new Error("Job creation failed")); + + const result = await heretto.loadHerettoContent(herettoConfig, mockLog, mockConfig); + + expect(result).to.be.null; + }); + }); + + describe("downloadAndExtractOutput", function () { + let herettoWithMocks; + let fsMock; + let admZipMock; + let mockEntries; + const mockLog = sinon.stub(); + const mockConfig = { logLevel: "info" }; + + beforeEach(function () { + mockLog.reset(); + + // Mock ZIP entries + mockEntries = [ + { entryName: "file1.dita", isDirectory: false, getData: () => Buffer.from("content1") }, + { entryName: "subdir/", isDirectory: true, getData: () => Buffer.from("") }, + { entryName: "subdir/file2.dita", isDirectory: false, getData: () => Buffer.from("content2") }, + ]; + + // Mock AdmZip + admZipMock = sinon.stub().returns({ + getEntries: () => mockEntries, + extractAllTo: sinon.stub(), + }); + + // Mock fs + fsMock = { + mkdirSync: sinon.stub(), + writeFileSync: sinon.stub(), + unlinkSync: sinon.stub(), + }; + + // Create heretto with mocked dependencies + herettoWithMocks = proxyquire("../src/heretto", { + axios: { create: axiosCreateStub }, + fs: fsMock, + "adm-zip": admZipMock, + }); + }); + + it("should download and extract ZIP file successfully", async function () { + const zipContent = Buffer.from("mock zip content"); + mockClient.get.resolves({ data: zipContent }); + + const result = await herettoWithMocks.downloadAndExtractOutput( + mockClient, + "file-uuid", + "job-123", + "test-heretto", + mockLog, + mockConfig + ); + + expect(result).to.not.be.null; + expect(result).to.include("heretto_"); + expect(fsMock.mkdirSync.called).to.be.true; + expect(fsMock.writeFileSync.called).to.be.true; + expect(fsMock.unlinkSync.called).to.be.true; + }); + + it("should return null when download fails", async function () { + mockClient.get.rejects(new Error("Download failed")); + + const result = await herettoWithMocks.downloadAndExtractOutput( + mockClient, + "file-uuid", + "job-123", + "test-heretto", + mockLog, + mockConfig + ); + + expect(result).to.be.null; + }); + + it("should skip malicious ZIP entries with path traversal", async function () { + // Add malicious entry + mockEntries.push({ + entryName: "../../../etc/passwd", + isDirectory: false, + getData: () => Buffer.from("malicious") + }); + + const zipContent = Buffer.from("mock zip content"); + mockClient.get.resolves({ data: zipContent }); + + const result = await herettoWithMocks.downloadAndExtractOutput( + mockClient, + "file-uuid", + "job-123", + "test-heretto", + mockLog, + mockConfig + ); + + expect(result).to.not.be.null; + // The warning log should be called for the malicious entry + expect(mockLog.called).to.be.true; + }); + }); + + describe("Constants", function () { + it("should export expected constants", function () { + expect(heretto.POLLING_INTERVAL_MS).to.equal(5000); + expect(heretto.POLLING_TIMEOUT_MS).to.equal(300000); + expect(heretto.DEFAULT_SCENARIO_NAME).to.equal("Doc Detective"); + }); + }); +}); diff --git a/src/index.js b/src/index.js index 4586e31..34ece77 100644 --- a/src/index.js +++ b/src/index.js @@ -34,7 +34,7 @@ async function detectAndResolveTests({ config }) { // Detect tests const detectedTests = await detectTests({ config }); if (!detectedTests || detectedTests.length === 0) { - log(config, "warn", "No tests detected."); + log(config, "warning", "No tests detected."); return null; } // Resolve tests diff --git a/src/utils.js b/src/utils.js index 481d609..4c32bb9 100644 --- a/src/utils.js +++ b/src/utils.js @@ -11,6 +11,7 @@ const { transformToSchemaKey, readFile, } = require("doc-detective-common"); +const { loadHerettoContent } = require("./heretto"); exports.qualifyFiles = qualifyFiles; exports.parseTests = parseTests; @@ -223,8 +224,67 @@ async function qualifyFiles({ config }) { const cleanup = config.afterAll; if (cleanup) sequence = sequence.concat(cleanup); + if (sequence.length === 0) { + log(config, "warning", "No input sources specified."); + return []; + } + + const ignoredDitaMaps = []; + for (let source of sequence) { log(config, "debug", `source: ${source}`); + + // Check if source is a heretto: reference + if (source.startsWith("heretto:")) { + const herettoName = source.substring(8); // Remove "heretto:" prefix + const herettoConfig = config?.integrations?.heretto?.find( + (h) => h.name === herettoName + ); + + if (!herettoConfig) { + log( + config, + "warning", + `Heretto integration "${herettoName}" not found in config. Skipping.` + ); + continue; + } + + // Load Heretto content if not already loaded + if (!herettoConfig.outputPath) { + try { + const outputPath = await loadHerettoContent(herettoConfig, log, config); + if (outputPath) { + herettoConfig.outputPath = outputPath; + log(config, "debug", `Adding Heretto output path: ${outputPath}`); + // Insert the output path into the sequence for processing + const currentIndex = sequence.indexOf(source); + sequence.splice(currentIndex + 1, 0, outputPath); + ignoredDitaMaps.push(outputPath); // DITA maps are already processed in Heretto + } else { + log( + config, + "warning", + `Failed to load Heretto content for "${herettoName}". Skipping.` + ); + } + } catch (error) { + log( + config, + "warning", + `Failed to load Heretto content from "${herettoName}": ${error.message}` + ); + } + } else { + // Already loaded, add to sequence if not already there + if (!sequence.includes(herettoConfig.outputPath)) { + const currentIndex = sequence.indexOf(source); + sequence.splice(currentIndex + 1, 0, herettoConfig.outputPath); + } + } + continue; + } + // Check if source is a URL let isURL = source.startsWith("http://") || source.startsWith("https://"); // If URL, fetch file and place in temp directory @@ -244,13 +304,15 @@ async function qualifyFiles({ config }) { if ( isFile && path.extname(source) === ".ditamap" && - config.processDitaMap + !ignoredDitaMaps.some((ignored) => source.includes(ignored)) && + config.processDitaMaps ) { const ditaOutput = await processDitaMap({ config, source }); if (ditaOutput) { // Add output directory to to sequence right after the ditamap file const currentIndex = sequence.indexOf(source); sequence.splice(currentIndex + 1, 0, ditaOutput); + ignoredDitaMaps.push(ditaOutput); // DITA maps are already processed locally } continue; }