diff --git a/lib/routes/veeam/get.js b/lib/routes/veeam/get.js index d802a29dfb..213d5c4866 100644 --- a/lib/routes/veeam/get.js +++ b/lib/routes/veeam/get.js @@ -1,9 +1,6 @@ -const xml2js = require('xml2js'); const { errors } = require('arsenal'); -const metadata = require('../../metadata/wrapper'); -const { respondWithData, buildHeadXML, getFileToBuild, isSystemXML } = require('./utils'); +const { respondWithData, buildHeadXML, buildVeeamFileData } = require('./utils'); const { responseXMLBody } = require('arsenal/build/lib/s3routes/routesUtils'); -const UtilizationService = require('../../../lib/utilization/instance'); /** * Returns system.xml or capacity.xml files for a given bucket. @@ -14,75 +11,22 @@ const UtilizationService = require('../../../lib/utilization/instance'); * @param {object} log - logger object * @returns {undefined} - */ -function getVeeamFile(request, response, bucketMd, log) { +async function getVeeamFile(request, response, bucketMd, log) { if (!bucketMd) { return responseXMLBody(errors.NoSuchBucket, null, response, log); } + if ('tagging' in request.query) { - return respondWithData(request, response, log, bucketMd, + return await respondWithData(request, response, log, bucketMd, buildHeadXML('')); } - return metadata.getBucket(request.bucketName, log, (err, data) => { - if (err) { - return responseXMLBody(errors.InternalError, null, response, log); - } - const finalizeRequest = bucketMetrics => { - const fileToBuild = getFileToBuild(request, data._capabilities?.VeeamSOSApi); - if (fileToBuild.error) { - return responseXMLBody(fileToBuild.error, null, response, log); - } - - // Extract the last modified date, but do not include it when computing - // the file's ETag (md5) - const modified = fileToBuild.value.LastModified; - delete fileToBuild.value.LastModified; - // The SOSAPI metrics are dynamically computed using the SUR backend. - if (bucketMetrics && !fileToBuild.value.CapacityInfo?.Used) { - fileToBuild.value.CapacityInfo.Used = Number(bucketMetrics.bytesTotal); - fileToBuild.value.CapacityInfo.Available = - Number(fileToBuild.value.CapacityInfo.Capacity) - Number(bucketMetrics.bytesTotal); - // TODO CLDSRV-633 when SUR backend supports realtime metrics: it will - // report the real last cseq/date processed by SUR, instead of the current date, - // ensuring no issue in a SOSAPI context. We should use this information. - } - - const builder = new xml2js.Builder({ - headless: true, - }); - return respondWithData(request, response, log, data, - buildHeadXML(builder.buildObject(fileToBuild.value)), modified); - }; - if (!isSystemXML(request.objectKey)) { - const bucketKey = `${bucketMd._name}_${new Date(bucketMd._creationDate).getTime()}`; - return UtilizationService.getUtilizationMetrics('bucket', bucketKey, null, {}, (err, bucketMetrics) => { - if (err) { - // Handle errors from UtilizationService/scubaclient - // axios errors have status in err.response.status - const statusCode = err.response?.status || err.statusCode || err.code; - // Only handle 404 gracefully (no metrics available yet, e.g. post-install) - // For 404, continue with static capacity data (Used=0 from bucket metadata) - if (statusCode === 404) { - log.warn('UtilizationService returned 404 when fetching capacity metrics', { - method: 'getVeeamFile', - bucket: request.bucketName, - error: err.message || err.code, - }); - return finalizeRequest(); - } - log.error('error fetching capacity metrics from UtilizationService', { - method: 'getVeeamFile', - bucket: request.bucketName, - error: err.message || err.code, - statusCode, - }); - return responseXMLBody(errors.InternalError, null, response, log); - } - return finalizeRequest(bucketMetrics); - }); - } - return finalizeRequest(); - }); + try { + const result = await buildVeeamFileData(request, bucketMd, log); + return await respondWithData(request, response, log, result.bucketData, result.xmlContent, result.modified); + } catch (err) { + return responseXMLBody(err, null, response, log); + } } module.exports = getVeeamFile; diff --git a/lib/routes/veeam/head.js b/lib/routes/veeam/head.js index b94624b364..5aeca5f78e 100644 --- a/lib/routes/veeam/head.js +++ b/lib/routes/veeam/head.js @@ -1,7 +1,5 @@ -const xml2js = require('xml2js'); const { errors } = require('arsenal'); -const metadata = require('../../metadata/wrapper'); -const { getResponseHeader, buildHeadXML, getFileToBuild } = require('./utils'); +const { getResponseHeader, buildVeeamFileData } = require('./utils'); const { responseXMLBody, responseContentHeaders } = require('arsenal/build/lib/s3routes/routesUtils'); /** @@ -13,31 +11,23 @@ const { responseXMLBody, responseContentHeaders } = require('arsenal/build/lib/s * @param {object} log - logger object * @returns {undefined} - */ -function headVeeamFile(request, response, bucketMd, log) { +async function headVeeamFile(request, response, bucketMd, log) { if (!bucketMd) { return responseXMLBody(errors.NoSuchBucket, null, response, log); } - return metadata.getBucket(request.bucketName, log, (err, data) => { - if (err) { - return responseXMLBody(errors.InternalError, null, response, log); - } - const fileToBuild = getFileToBuild(request, data._capabilities?.VeeamSOSApi); - if (fileToBuild.error) { - return responseXMLBody(fileToBuild.error, null, response, log); - } - let modified = new Date().toISOString(); - // Extract the last modified date, but do not include it when computing - // the file's ETag (md5) - modified = fileToBuild.value.LastModified; - delete fileToBuild.value.LastModified; - // Recompute file content to generate appropriate content-md5 header - const builder = new xml2js.Builder({ - headless: true, - }); - const dataBuffer = Buffer.from(buildHeadXML(builder.buildObject(fileToBuild))); - return responseContentHeaders(null, {}, getResponseHeader(request, data, - dataBuffer, modified, log), response, log); - }); + + try { + const result = await buildVeeamFileData(request, bucketMd, log); + return responseContentHeaders( + null, + {}, + getResponseHeader(request, result.bucketData, result.dataBuffer, result.modified, log), + response, + log, + ); + } catch (err) { + return responseXMLBody(err, null, response, log); + } } module.exports = headVeeamFile; diff --git a/lib/routes/veeam/list.js b/lib/routes/veeam/list.js index 38fd0293ee..175fbb77b4 100644 --- a/lib/routes/veeam/list.js +++ b/lib/routes/veeam/list.js @@ -1,13 +1,10 @@ const url = require('url'); -const xml2js = require('xml2js'); const { errors, errorInstances } = require('arsenal'); const querystring = require('querystring'); -const metadata = require('../../metadata/wrapper'); const { responseXMLBody } = require('arsenal/build/lib/s3routes/routesUtils'); -const { respondWithData, getResponseHeader, buildHeadXML, validPath } = require('./utils'); +const { respondWithData, getResponseHeader, buildXML, validPath, fetchCapacityMetrics, getBucket } = require('./utils'); const { processVersions, processMasterVersions } = require('../../api/bucketGet'); - /** * Utility function to build a standard response for the LIST route. * It adds the supported path by default as a static and default file. @@ -83,51 +80,67 @@ function buildXMLResponse(request, arrayOfFiles, versioned = false) { * @param {object} log - logger object * @returns {undefined} - */ -function listVeeamFiles(request, response, bucketMd, log) { +async function listVeeamFiles(request, response, bucketMd, log) { if (!bucketMd) { return responseXMLBody(errors.NoSuchBucket, null, response, log); } + // Only accept list-type query parameter if (!('list-type' in request.query) && !('versions' in request.query)) { return responseXMLBody(errorInstances.InvalidRequest .customizeDescription('The Veeam folder does not support this action.'), null, response, log); } - return metadata.getBucket(request.bucketName, log, (err, data) => { - if (err) { + + let data; + try { + data = await getBucket(request.bucketName, log); + } catch { + return responseXMLBody(errors.InternalError, null, response, log); + } + + let bucketMetrics; + if (data._capabilities?.VeeamSOSApi?.CapacityInfo) { + try { + bucketMetrics = await fetchCapacityMetrics(bucketMd, request, log); + } catch { return responseXMLBody(errors.InternalError, null, response, log); } - const filesToBuild = []; - const fieldsToGenerate = []; - if (data._capabilities?.VeeamSOSApi?.SystemInfo) { - fieldsToGenerate.push({ - ...data._capabilities?.VeeamSOSApi?.SystemInfo, - name: `${validPath}system.xml`, - }); - } - if (data._capabilities?.VeeamSOSApi?.CapacityInfo) { - fieldsToGenerate.push({ - ...data._capabilities?.VeeamSOSApi?.CapacityInfo, - name: `${validPath}capacity.xml`, - }); - } - fieldsToGenerate.forEach(file => { - const lastModified = file.LastModified; - // eslint-disable-next-line no-param-reassign - delete file.LastModified; - const builder = new xml2js.Builder({ - headless: true, - }); - const dataBuffer = Buffer.from(buildHeadXML(builder.buildObject(file))); - filesToBuild.push({ - ...getResponseHeader(request, data, - dataBuffer, lastModified, log), - name: file.name, - }); + } else { + bucketMetrics = { date: new Date() }; + } + + const filesToBuild = []; + const fieldsToGenerate = []; + if (data._capabilities?.VeeamSOSApi?.SystemInfo) { + fieldsToGenerate.push({ + ...data._capabilities?.VeeamSOSApi?.SystemInfo, + name: `${validPath}system.xml`, + }); + } + if (data._capabilities?.VeeamSOSApi?.CapacityInfo) { + fieldsToGenerate.push({ + ...data._capabilities?.VeeamSOSApi?.CapacityInfo, + name: `${validPath}capacity.xml`, + }); + } + fieldsToGenerate.forEach(file => { + const isCapacity = file.name.endsWith('capacity.xml'); + const lastModified = isCapacity + ? bucketMetrics.date + : file.LastModified; + // eslint-disable-next-line no-param-reassign + delete file.LastModified; + const dataBuffer = Buffer.from(buildXML(file)); + filesToBuild.push({ + ...getResponseHeader(request, data, + dataBuffer, lastModified, log), + name: file.name, }); - // When `versions` is present, listing should return a versioned list - return respondWithData(request, response, log, data, - buildXMLResponse(request, filesToBuild, 'versions' in request.query)); }); + + // When `versions` is present, listing should return a versioned list + return await respondWithData(request, response, log, data, + buildXMLResponse(request, filesToBuild, 'versions' in request.query)); } module.exports = listVeeamFiles; diff --git a/lib/routes/veeam/put.js b/lib/routes/veeam/put.js index 521d0d7d59..0e15d86051 100644 --- a/lib/routes/veeam/put.js +++ b/lib/routes/veeam/put.js @@ -1,4 +1,5 @@ const async = require('async'); +const { callbackify } = require('util'); const { parseString } = require('xml2js'); const { receiveData, isSystemXML, getFileToBuild } = require('./utils'); const { s3routes, errors } = require('arsenal'); @@ -28,7 +29,7 @@ function putVeeamFile(request, response, bucketMd, log) { next => { // Extract the data from the request, keep it in memory writeContinue(request, response); - return receiveData(request, log, next); + return callbackify(receiveData)(request, log, next); }, (value, next) => parseString(value, { explicitArray: false }, (err, parsed) => { // Convert the received XML to a JS object diff --git a/lib/routes/veeam/utils.js b/lib/routes/veeam/utils.js index 5ab082c6d5..3676669435 100644 --- a/lib/routes/veeam/utils.js +++ b/lib/routes/veeam/utils.js @@ -1,9 +1,17 @@ +const xml2js = require('xml2js'); const { errors, errorInstances, jsutil } = require('arsenal'); -const { Readable } = require('stream'); +const { Readable, Writable, pipeline: streamPipeline } = require('stream'); +const { promisify } = require('util'); const collectResponseHeaders = require('../../utilities/collectResponseHeaders'); const collectCorsHeaders = require('../../utilities/collectCorsHeaders'); const crypto = require('crypto'); const { prepareStream } = require('arsenal/build/lib/s3middleware/prepareStream'); +const UtilizationService = require('../../utilization/instance'); +const metadata = require('../../metadata/wrapper'); + +const pipeline = promisify(streamPipeline); +const getUtilizationMetrics = promisify((...args) => UtilizationService.getUtilizationMetrics(...args)); +const getBucket = promisify((...args) => metadata.getBucket(...args)); /** * Decodes an URI and return the result. @@ -20,43 +28,44 @@ function _decodeURI(uri) { * * @param {object} request - incoming request * @param {object} log - logger object - * @param {function} callback - - * @returns {undefined} + * @returns {Promise} */ -function receiveData(request, log, callback) { - // Get keycontent +async function receiveData(request, log) { const { parsedContentLength } = request; const ContentLengthThreshold = 1024 * 1024; // 1MB + // Prevent memory overloads by limiting the size of the // received data. if (parsedContentLength > ContentLengthThreshold) { - return callback(errorInstances.InvalidInput - .customizeDescription(`maximum allowed content-length is ${ContentLengthThreshold} bytes`)); + throw errorInstances.InvalidInput + .customizeDescription(`maximum allowed content-length is ${ContentLengthThreshold} bytes`); } - const value = Buffer.alloc(parsedContentLength); - const cbOnce = jsutil.once(callback); - const dataStream = prepareStream(request, request.streamingV4Params, log, cbOnce); - let cursor = 0; - let exceeded = false; - dataStream.on('data', data => { - if (cursor + data.length > parsedContentLength) { - exceeded = true; - } - if (!exceeded) { - data.copy(value, cursor); - } - cursor += data.length; - }); - dataStream.on('end', () => { - if (exceeded) { - log.error('data stream exceed announced size', - { parsedContentLength, overflow: cursor }); - return callback(errors.InternalError); - } else { - return callback(null, value.toString()); - } + return await new Promise((resolve, reject) => { + const settle = jsutil.once((err, result) => { + if (err) { return reject(err); } + return resolve(result); + }); + let totalLength = 0; + const chunks = []; + const collector = new Writable({ + write(chunk, _enc, cb) { + totalLength += chunk.length; + if (totalLength > parsedContentLength) { + log.error('data stream exceed announced size', + { parsedContentLength, overflow: totalLength }); + return cb(errors.InternalError); + } + chunks.push(chunk); + return cb(); + }, + final(cb) { + settle(null, Buffer.concat(chunks).toString()); + cb(); + }, + }); + const dataStream = prepareStream(request, request.streamingV4Params, log, settle); + pipeline(dataStream, collector).catch(err => settle(err)); }); - return undefined; } /** @@ -95,6 +104,17 @@ function getResponseHeader(request, bucket, dataBuffer, lastModified, log) { responseMetaHeaders['x-amz-request-id'] = log.getSerializedUids(); return responseMetaHeaders; } +/** + * Builds a headless XML string wrapped in the standard SOSAPI XML declaration. + * + * @param {object} obj - JS object to serialize to XML + * @returns {string} formatted XML file content + */ +function buildXML(obj) { + const builder = new xml2js.Builder({ headless: true }); + return buildHeadXML(builder.buildObject(obj)); +} + /** * Generic function to respond to user with data using streams * @@ -104,23 +124,12 @@ function getResponseHeader(request, bucket, dataBuffer, lastModified, log) { * @param {BucketInfo} bucket - bucket info * @param {string} data - data to send * @param {date} [lastModified] - last modified date of the value - * @returns {undefined} - + * @returns {Promise} - */ -function respondWithData(request, response, log, bucket, data, lastModified) { +async function respondWithData(request, response, log, bucket, data, lastModified) { const dataBuffer = Buffer.from(data); const responseMetaHeaders = getResponseHeader(request, bucket, dataBuffer, lastModified, log); - response.on('finish', () => { - let contentLength = 0; - if (responseMetaHeaders && responseMetaHeaders['Content-Length']) { - contentLength = responseMetaHeaders['Content-Length']; - } - log.end().addDefaultFields({ contentLength }); - log.end().info('responded with streamed content', { - httpCode: response.statusCode, - }); - }); - if (responseMetaHeaders && typeof responseMetaHeaders === 'object') { Object.keys(responseMetaHeaders).forEach(key => { if (responseMetaHeaders[key] !== undefined) { @@ -138,14 +147,26 @@ function respondWithData(request, response, log, bucket, data, lastModified) { } response.writeHead(200); - const stream = Readable.from(dataBuffer); - stream.pipe(response); - stream.on('unpipe', () => { - response.end(); - }); - stream.on('error', () => { - response.end(); - }); + + let contentLength = 0; + if (responseMetaHeaders && responseMetaHeaders['Content-Length']) { + contentLength = responseMetaHeaders['Content-Length']; + } + log.end().addDefaultFields({ contentLength }); + + try { + // Use a single-element array so the Buffer is sent as one chunk rather + // than being iterated byte-by-byte by Readable.from. + await pipeline(Readable.from([dataBuffer]), response); + log.end().info('responded with streamed content', { + httpCode: response.statusCode, + }); + } catch (err) { + log.end().error('error streaming response', { + httpCode: response.statusCode, + error: err.message, + }); + } } const validPath = '.system-d26a9498-cb7c-4a87-a44a-8ae204f5ba6c/'; @@ -172,31 +193,113 @@ function isSystemXML(objectKey) { */ function getFileToBuild(request, data, inlineLastModified = false) { const _isSystemXML = isSystemXML(request.objectKey); - const fileToBuild = _isSystemXML ? data?.SystemInfo - : data?.CapacityInfo; + const fileToBuild = _isSystemXML ? data?.SystemInfo : data?.CapacityInfo; + if (!fileToBuild) { return { error: errors.NoSuchKey }; } + const modified = fileToBuild.LastModified || (new Date()).toISOString(); const fieldName = _isSystemXML ? 'SystemInfo' : 'CapacityInfo'; + if (inlineLastModified) { fileToBuild.LastModified = modified; - return { - value: { - [fieldName]: fileToBuild, - }, - fieldName, - }; } else { delete fileToBuild.LastModified; - return { - value: { - [fieldName]: fileToBuild, - LastModified: modified, - }, - fieldName, - }; } + + return { + value: { + [fieldName]: fileToBuild, + }, + fieldName, + }; +} + +/** + * Fetches capacity metrics from UtilizationService for a bucket. + * Handles 404 gracefully (no metrics available yet, e.g. post-install), + * returning a default bucketMetrics with the current date so callers always + * receive a usable object. + * + * @param {object} bucketMd - bucket metadata + * @param {object} request - incoming request + * @param {object} log - logger object + * @returns {Promise} bucketMetrics always has at least a `date` field; + * on a real 404 the date defaults to new Date() + */ +async function fetchCapacityMetrics(bucketMd, request, log) { + const bucketKey = `${bucketMd._name}_${new Date(bucketMd._creationDate).getTime()}`; + try { + return await getUtilizationMetrics('bucket', bucketKey, null, {}); + } catch (err) { + const statusCode = err.response?.status || err.statusCode || err.code; + if (statusCode === 404) { + log.warn('UtilizationService returned 404 when fetching capacity metrics', { + bucket: request.bucketName, + error: err.message || err.code, + }); + return { date: new Date() }; + } + log.error('error fetching capacity metrics from UtilizationService', { + bucket: request.bucketName, + error: err.message || err.code, + statusCode, + }); + throw err; + } +} + +/** + * Builds Veeam file data (XML content + response metadata) for a given request. + * + * @param {object} request - incoming request + * @param {object} bucketMd - bucket metadata from the router + * @param {object} log - logger object + * @returns {Promise} result with { xmlContent, dataBuffer, modified, bucketData } + */ +async function buildVeeamFileData(request, bucketMd, log) { + let data; + try { + data = await getBucket(request.bucketName, log); + } catch { + log.error('error fetching bucket metadata', { bucket: request.bucketName }); + throw errors.InternalError; + } + + const fileToBuild = getFileToBuild(request, data._capabilities?.VeeamSOSApi); + + if (fileToBuild.error) { + throw fileToBuild.error; + } + + let bucketMetrics; + if (!isSystemXML(request.objectKey)) { + try { + bucketMetrics = await fetchCapacityMetrics(bucketMd, request, log); + } catch { + log.error('error fetching capacity metrics for bucket', { bucket: request.bucketName }); + throw errors.InternalError; + } + } else { + bucketMetrics = { date: new Date() }; + } + + const modified = bucketMetrics.date; + if (bucketMetrics.bytesTotal !== undefined + && fileToBuild.value.CapacityInfo + && !fileToBuild.value.CapacityInfo.Used) { + fileToBuild.value.CapacityInfo.Used = Number(bucketMetrics.bytesTotal); + fileToBuild.value.CapacityInfo.Available = + Number(fileToBuild.value.CapacityInfo.Capacity) - Number(bucketMetrics.bytesTotal); + // TODO CLDSRV-633 when SUR backend supports realtime metrics: it will + // report the real last cseq/date processed by SUR, instead of the current date, + // ensuring no issue in a SOSAPI context. We should use this information. + } + + const xmlContent = buildXML(fileToBuild.value); + const dataBuffer = Buffer.from(xmlContent); + return { xmlContent, dataBuffer, modified, bucketData: data }; } module.exports = { @@ -205,7 +308,11 @@ module.exports = { respondWithData, getResponseHeader, buildHeadXML, + buildXML, validPath, isSystemXML, getFileToBuild, + fetchCapacityMetrics, + buildVeeamFileData, + getBucket, }; diff --git a/package.json b/package.json index 9f0dfe709e..c3dabd623d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@zenko/cloudserver", - "version": "9.3.6", + "version": "9.3.7", "description": "Zenko CloudServer, an open-source Node.js implementation of a server handling the Amazon S3 protocol", "main": "index.js", "engines": { diff --git a/tests/unit/api/apiUtils/objectLockHelpers.js b/tests/unit/api/apiUtils/objectLockHelpers.js index c0c445cef6..285efc9c8f 100644 --- a/tests/unit/api/apiUtils/objectLockHelpers.js +++ b/tests/unit/api/apiUtils/objectLockHelpers.js @@ -172,7 +172,7 @@ describe('objectLockHelpers: calculateRetainUntilDate', () => { }; const date = moment(); const expectedRetainUntilDate - = date.add(mockConfigWithYears.years * 365, 'days'); + = date.add(mockConfigWithYears.years * 365 * 86400000, 'ms'); const retainUntilDate = calculateRetainUntilDate(mockConfigWithYears); assert.strictEqual(retainUntilDate.slice(0, 16), expectedRetainUntilDate.toISOString().slice(0, 16)); diff --git a/tests/unit/routes/veeam-routes.js b/tests/unit/routes/veeam-routes.js index 162e98eb49..f3eb3d9039 100644 --- a/tests/unit/routes/veeam-routes.js +++ b/tests/unit/routes/veeam-routes.js @@ -7,9 +7,6 @@ const UtilizationService = require('../../../lib/utilization/instance'); const metadata = require('../../../lib/metadata/wrapper'); const { DummyRequestLogger } = require('../helpers'); -// Helper function to give async callbacks time to execute -const giveAsyncCallbackTimeToExecute = setImmediate; - describe('Veeam routes - comprehensive unit tests', () => { let utilizationStub; let metadataStub; @@ -104,7 +101,7 @@ describe('Veeam routes - comprehensive unit tests', () => { return response; }; - it('should handle 404 error from UtilizationService and return 200', done => { + it('should handle 404 error from UtilizationService and return 200', async () => { const error404 = new Error('Not Found'); error404.response = { status: 404 }; utilizationStub.callsArgWith(4, error404); @@ -112,24 +109,20 @@ describe('Veeam routes - comprehensive unit tests', () => { const request = createRequest(); const response = createResponse(); - getVeeamFile(request, response, bucketMd, log); + await getVeeamFile(request, response, bucketMd, log); - giveAsyncCallbackTimeToExecute(() => { - assert(logWarnSpy.calledOnce, 'log.warn should have been called once'); - const warnCall = logWarnSpy.getCall(0); - assert(warnCall.args[0].includes('UtilizationService returned 404'), - 'warning message should mention 404'); - assert.strictEqual(warnCall.args[1].method, 'getVeeamFile'); - assert.strictEqual(warnCall.args[1].bucket, 'test-bucket'); + assert(logWarnSpy.calledOnce, 'log.warn should have been called once'); + const warnCall = logWarnSpy.getCall(0); + assert(warnCall.args[0].includes('UtilizationService returned 404'), + 'warning message should mention 404'); + assert.strictEqual(warnCall.args[1].bucket, 'test-bucket'); - assert(response.writeHead.calledWith(200), - 'should return 200 despite 404 from UtilizationService'); - assert(response.end.called, 'response should be ended'); - done(); - }); + assert(response.writeHead.calledWith(200), + 'should return 200 despite 404 from UtilizationService'); + assert(response.end.called, 'response should be ended'); }); - it('should handle 500 error from UtilizationService and return 500', done => { + it('should handle 500 error from UtilizationService and return 500', async () => { const error500 = new Error('Internal Server Error'); error500.response = { status: 500 }; utilizationStub.callsArgWith(4, error500); @@ -137,16 +130,13 @@ describe('Veeam routes - comprehensive unit tests', () => { const request = createRequest(); const response = createResponse(); - getVeeamFile(request, response, bucketMd, log); + await getVeeamFile(request, response, bucketMd, log); - giveAsyncCallbackTimeToExecute(() => { - assert(response.headersSent || response.write.called || response.writeHead.called, - 'should send error response for 500 errors'); - done(); - }); + assert(response.headersSent || response.write.called || response.writeHead.called, + 'should send error response for 500 errors'); }); - it('should handle connection error from UtilizationService and return 500', done => { + it('should handle connection error from UtilizationService and return 500', async () => { const errorConn = new Error('Connection refused'); errorConn.code = 'ECONNREFUSED'; utilizationStub.callsArgWith(4, errorConn); @@ -154,36 +144,42 @@ describe('Veeam routes - comprehensive unit tests', () => { const request = createRequest(); const response = createResponse(); - getVeeamFile(request, response, bucketMd, log); + await getVeeamFile(request, response, bucketMd, log); - giveAsyncCallbackTimeToExecute(() => { - assert(response.headersSent || response.write.called || response.writeHead.called, - 'should send error response for connection errors'); - done(); - }); + assert(response.headersSent || response.write.called || response.writeHead.called, + 'should send error response for connection errors'); }); - it('should successfully use metrics when UtilizationService returns data', done => { + it('should successfully use metrics when UtilizationService returns data', async () => { + const metricsDate = '2026-03-26T19:00:08.996Z'; const bucketMetrics = { bytesTotal: 123456789, + date: metricsDate, }; utilizationStub.callsArgWith(4, null, bucketMetrics); const request = createRequest(); const response = createResponse(); - getVeeamFile(request, response, bucketMd, log); + await getVeeamFile(request, response, bucketMd, log); - giveAsyncCallbackTimeToExecute(() => { - assert(!logWarnSpy.called, 'log.warn should not have been called'); - assert(response.writeHead.calledWith(200), 'should return 200 with metrics'); - assert(utilizationStub.calledOnce, 'should call UtilizationService once'); - assert(response.end.called, 'response should be ended'); - done(); - }); + assert(!logWarnSpy.called, 'log.warn should not have been called'); + assert(response.writeHead.calledWith(200), 'should return 200 with metrics'); + assert(utilizationStub.calledOnce, 'should call UtilizationService once'); + assert(response.end.called, 'response should be ended'); + + const lastModifiedCall = response.setHeader.getCalls() + .find(call => call.args[0] === 'Last-Modified'); + + assert(lastModifiedCall, 'Last-Modified header should be set'); + assert.strictEqual( + lastModifiedCall.args[1], + new Date(metricsDate).toUTCString(), + 'Last-Modified should use the date from bucketMetrics', + ); }); - it('should not call UtilizationService for system.xml requests', done => { + it('should not call UtilizationService for system.xml requests', async () => { const bucketMdWithSystem = { ...bucketMd, _capabilities: { @@ -201,17 +197,14 @@ describe('Veeam routes - comprehensive unit tests', () => { const request = createRequest('.system-d26a9498-cb7c-4a87-a44a-8ae204f5ba6c/system.xml'); const response = createResponse(); - getVeeamFile(request, response, bucketMdWithSystem, log); + await getVeeamFile(request, response, bucketMdWithSystem, log); - setImmediate(() => { - assert(!utilizationStub.called, 'should not call UtilizationService for system.xml'); - assert(response.writeHead.calledWith(200), 'should return 200 for system.xml'); - assert(response.end.called, 'response should be ended'); - done(); - }); + assert(!utilizationStub.called, 'should not call UtilizationService for system.xml'); + assert(response.writeHead.calledWith(200), 'should return 200 for system.xml'); + assert(response.end.called, 'response should be ended'); }); - it('should verify the post-install scenario: 404 returns 200 with Used=0', done => { + it('should verify the post-install scenario: 404 returns 200 with Used=0', async () => { // This test reproduces the post-install scenario where scubaclient returns 404 // because no metrics are available yet const error404 = new Error('Not Found'); @@ -221,53 +214,44 @@ describe('Veeam routes - comprehensive unit tests', () => { const request = createRequest(); const response = createResponse(); - getVeeamFile(request, response, bucketMd, log); + await getVeeamFile(request, response, bucketMd, log); - giveAsyncCallbackTimeToExecute(() => { - assert(logWarnSpy.calledOnce, 'should log warning for 404'); - assert(response.writeHead.calledWith(200), - 'should return 200 with static capacity data for 404'); - assert(response.end.called, 'response should be ended'); - const warnCall = logWarnSpy.getCall(0); - assert(warnCall.args[0].includes('404'), 'warning should mention 404'); - - done(); - }); + assert(logWarnSpy.calledOnce, 'should log warning for 404'); + assert(response.writeHead.calledWith(200), + 'should return 200 with static capacity data for 404'); + assert(response.end.called, 'response should be ended'); + const warnCall = logWarnSpy.getCall(0); + assert(warnCall.args[0].includes('404'), 'warning should mention 404'); }); - it('should handle metadata.getBucket errors gracefully', done => { + it('should handle metadata.getBucket errors gracefully', async () => { const metadataError = new Error('Metadata service error'); metadataStub.callsArgWith(2, metadataError); const request = createRequest(); const response = createResponse(); - getVeeamFile(request, response, bucketMd, log); + await getVeeamFile(request, response, bucketMd, log); - giveAsyncCallbackTimeToExecute(() => { - assert(response.headersSent || response.write.called || response.writeHead.called, - 'should send response for metadata errors'); - done(); - }); + assert(response.headersSent || response.write.called || response.writeHead.called, + 'should send response for metadata errors'); }); - it('should handle tagging query parameter', done => { + it('should handle tagging query parameter', async () => { const request = createRequest(); request.query = { tagging: '' }; const response = createResponse(); - getVeeamFile(request, response, bucketMd, log); + await getVeeamFile(request, response, bucketMd, log); - giveAsyncCallbackTimeToExecute(() => { - assert(response.writeHead.calledWith(200), - 'should return 200 for tagging query'); - assert(response.end.called, 'response should be ended'); - done(); - }); + assert(response.writeHead.calledWith(200), + 'should return 200 for tagging query'); + assert(response.end.called, 'response should be ended'); }); }); describe('Veeam routes - HEAD request UtilizationService error handling', () => { + let utilizationStub; let metadataStub; let log; let logWarnSpy; @@ -300,6 +284,7 @@ describe('Veeam routes - HEAD request UtilizationService error handling', () => log.end = sinon.stub().returns(logEndStub); log.debug = sinon.stub(); + utilizationStub = sinon.stub(UtilizationService, 'getUtilizationMetrics'); metadataStub = sinon.stub(metadata, 'getBucket'); metadataStub.callsArgWith(2, null, bucketMd, undefined); }); @@ -338,7 +323,7 @@ describe('Veeam routes - HEAD request UtilizationService error handling', () => return response; }; - it('should handle HEAD request for system.xml', done => { + it('should handle HEAD request for system.xml without calling UtilizationService', async () => { const bucketMdWithSystem = { ...bucketMd, _capabilities: { @@ -356,49 +341,87 @@ describe('Veeam routes - HEAD request UtilizationService error handling', () => const request = createRequest('.system-d26a9498-cb7c-4a87-a44a-8ae204f5ba6c/system.xml'); const response = createResponse(); - headVeeamFile(request, response, bucketMdWithSystem, log); + await headVeeamFile(request, response, bucketMdWithSystem, log); - giveAsyncCallbackTimeToExecute(() => { - assert(response.setHeader.called, 'should set headers'); - assert(response.end.called, 'response should be ended'); - done(); - }); + assert(!utilizationStub.called, 'should not call UtilizationService for system.xml'); + assert(response.setHeader.called, 'should set headers'); + assert(response.end.called, 'response should be ended'); }); - it('should handle HEAD request for capacity.xml', done => { + it('should call UtilizationService for capacity.xml and use metrics date', async () => { + const metricsDate = '2026-03-26T19:00:08.996Z'; + utilizationStub.callsArgWith(4, null, { bytesTotal: 123456789, date: metricsDate }); + const request = createRequest('.system-d26a9498-cb7c-4a87-a44a-8ae204f5ba6c/capacity.xml'); const response = createResponse(); - headVeeamFile(request, response, bucketMd, log); + await headVeeamFile(request, response, bucketMd, log); - giveAsyncCallbackTimeToExecute(() => { - assert(response.setHeader.called, 'should set headers'); - assert(response.end.called, 'response should be ended'); - done(); - }); + assert(utilizationStub.calledOnce, 'should call UtilizationService once'); + assert(response.setHeader.called, 'should set headers'); + assert(response.end.called, 'response should be ended'); + + const lastModifiedCall = response.setHeader.getCalls() + .find(call => call.args[0] === 'Last-Modified'); + assert(lastModifiedCall, 'Last-Modified header should be set'); + assert.strictEqual( + lastModifiedCall.args[1], + new Date(metricsDate).toUTCString(), + 'Last-Modified should use the date from bucketMetrics', + ); }); - it('should return 404 when no VeeamSOSApi capabilities', done => { + it('should handle 404 from UtilizationService on HEAD and return 200', async () => { + const error404 = new Error('Not Found'); + error404.response = { status: 404 }; + utilizationStub.callsArgWith(4, error404); + + const request = createRequest('.system-d26a9498-cb7c-4a87-a44a-8ae204f5ba6c/capacity.xml'); + const response = createResponse(); + + await headVeeamFile(request, response, bucketMd, log); + + assert(logWarnSpy.calledOnce, 'log.warn should have been called once'); + const warnCall = logWarnSpy.getCall(0); + assert(warnCall.args[0].includes('UtilizationService returned 404'), + 'warning message should mention 404'); + assert(response.setHeader.called, 'should set headers'); + assert(response.end.called, 'response should be ended'); + }); + + it('should handle non-404 error from UtilizationService on HEAD and return 500', async () => { + const error500 = new Error('Internal Server Error'); + error500.response = { status: 500 }; + utilizationStub.callsArgWith(4, error500); + + const request = createRequest('.system-d26a9498-cb7c-4a87-a44a-8ae204f5ba6c/capacity.xml'); + const response = createResponse(); + + await headVeeamFile(request, response, bucketMd, log); + + assert(response.end.called, 'response should be ended'); + }); + + it('should return 404 when no VeeamSOSApi capabilities', async () => { const bucketMdWithoutVeeam = { ...bucketMd, _capabilities: {}, }; metadataStub.callsArgWith(2, null, bucketMdWithoutVeeam); + utilizationStub.callsArgWith(4, null, {}); const request = createRequest(); const response = createResponse(); - headVeeamFile(request, response, bucketMdWithoutVeeam, log); + await headVeeamFile(request, response, bucketMdWithoutVeeam, log); - giveAsyncCallbackTimeToExecute(() => { - // HEAD should return 404 via headers, not body - assert(response.end.called, 'response should be ended'); - done(); - }); + // HEAD should return 404 via headers, not body + assert(response.end.called, 'response should be ended'); }); }); describe('Veeam routes - LIST request handling', () => { + let utilizationStub; let metadataStub; let log; let logWarnSpy; @@ -437,6 +460,7 @@ describe('Veeam routes - LIST request handling', () => { log.debug = sinon.stub(); log.trace = sinon.stub(); + utilizationStub = sinon.stub(UtilizationService, 'getUtilizationMetrics'); metadataStub = sinon.stub(metadata, 'getBucket'); metadataStub.callsArgWith(2, null, bucketMd, undefined); }); @@ -480,20 +504,23 @@ describe('Veeam routes - LIST request handling', () => { return response; }; - it('should list both system.xml and capacity.xml when both are present', done => { + it('should list both system.xml and capacity.xml when both are present', async () => { + const metricsDate = '2026-03-26T19:00:08.996Z'; + utilizationStub.callsArgWith(4, null, { bytesTotal: 123456789, date: metricsDate }); + const request = createRequest(); const response = createResponse(); - listVeeamFiles(request, response, bucketMd, log); + await listVeeamFiles(request, response, bucketMd, log); - giveAsyncCallbackTimeToExecute(() => { - assert(response.writeHead.calledWith(200), 'should return 200'); - assert(response.end.called, 'response should be ended'); - done(); - }); + assert(utilizationStub.calledOnce, 'should call UtilizationService once'); + assert(response.writeHead.calledWith(200), 'should return 200'); + assert(response.end.called, 'response should be ended'); }); - it('should emit LastModified in ISO 8601 format in XML body', done => { + it('should emit LastModified in ISO 8601 format in XML body', async () => { + utilizationStub.callsArgWith(4, null, { bytesTotal: 0, date: new Date().toISOString() }); + const request = createRequest(); const response = createResponse(); @@ -503,98 +530,116 @@ describe('Veeam routes - LIST request handling', () => { return true; }); - listVeeamFiles(request, response, bucketMd, log); - - giveAsyncCallbackTimeToExecute(() => { - assert(response.writeHead.calledWith(200), 'should return 200'); + await listVeeamFiles(request, response, bucketMd, log); - const body = xmlChunks.join(''); - assert(body.length > 0, 'response body should not be empty'); + assert(response.writeHead.calledWith(200), 'should return 200'); - const lastModifiedRegex = /([^<]+)<\/LastModified>/g; - const matches = []; - let match; - while ((match = lastModifiedRegex.exec(body)) !== null) { - matches.push(match[1]); - } + const body = xmlChunks.join(''); + assert(body.length > 0, 'response body should not be empty'); - assert.strictEqual(matches.length, 3, - 'should have 3 LastModified entries (system.xml, capacity.xml, folder)'); + const lastModifiedRegex = /([^<]+)<\/LastModified>/g; + const matches = []; + let match; + while ((match = lastModifiedRegex.exec(body)) !== null) { + matches.push(match[1]); + } - const iso8601Regex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/; - matches.forEach(value => { - assert(iso8601Regex.test(value), - `LastModified "${value}" should be in ISO 8601 format`); - }); + assert.strictEqual(matches.length, 3, + 'should have 3 LastModified entries (system.xml, capacity.xml, folder)'); - done(); + const iso8601Regex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/; + matches.forEach( + value => { assert(iso8601Regex.test(value), `LastModified "${value}" should be in ISO 8601 format`); }); }); - it('should handle versions query parameter', done => { + it('should handle 404 from UtilizationService on LIST and return 200', async () => { + const error404 = new Error('Not Found'); + error404.response = { status: 404 }; + utilizationStub.callsArgWith(4, error404); + + const request = createRequest(); + const response = createResponse(); + + await listVeeamFiles(request, response, bucketMd, log); + + assert(logWarnSpy.calledOnce, 'log.warn should have been called once'); + const warnCall = logWarnSpy.getCall(0); + assert(warnCall.args[0].includes('UtilizationService returned 404'), + 'warning message should mention 404'); + assert(response.writeHead.calledWith(200), 'should return 200 despite 404'); + assert(response.end.called, 'response should be ended'); + }); + + it('should handle non-404 error from UtilizationService on LIST and return 500', async () => { + const error500 = new Error('Internal Server Error'); + error500.response = { status: 500 }; + utilizationStub.callsArgWith(4, error500); + + const request = createRequest(); + const response = createResponse(); + + await listVeeamFiles(request, response, bucketMd, log); + + assert(response.end.called, 'response should be ended'); + }); + + it('should handle versions query parameter', async () => { + utilizationStub.callsArgWith(4, null, { bytesTotal: 0, date: new Date().toISOString() }); const request = createRequest({ versions: '' }); const response = createResponse(); - listVeeamFiles(request, response, bucketMd, log); + await listVeeamFiles(request, response, bucketMd, log); - giveAsyncCallbackTimeToExecute(() => { - assert(response.writeHead.calledWith(200), 'should return 200 for versions query'); - assert(response.end.called, 'response should be ended'); - done(); - }); + assert(response.writeHead.calledWith(200), 'should return 200 for versions query'); + assert(response.end.called, 'response should be ended'); }); - it('should return error for invalid query parameters', done => { + it('should return error for invalid query parameters', async () => { const request = createRequest({ 'invalid-param': 'value' }); const response = createResponse(); - listVeeamFiles(request, response, bucketMd, log); + await listVeeamFiles(request, response, bucketMd, log); - giveAsyncCallbackTimeToExecute(() => { - // Should return error for invalid query parameter - assert(response.end.called, 'response should be ended'); - done(); - }); + // Should return error for invalid query parameter + assert(response.end.called, 'response should be ended'); }); - it('should handle missing bucket metadata', done => { + it('should handle missing bucket metadata', async () => { const request = createRequest(); const response = createResponse(); - listVeeamFiles(request, response, null, log); + await listVeeamFiles(request, response, null, log); - giveAsyncCallbackTimeToExecute(() => { - assert(response.writeHead.calledWith(404), 'should return 404'); - assert(response.end.called, 'response should be ended'); - done(); - }); + assert(response.writeHead.calledWith(404), 'should return 404'); + assert(response.end.called, 'response should be ended'); }); - it('should list only available files when some capabilities are missing', done => { - const bucketMdOnlySystem = { - ...bucketMd, - _capabilities: { - VeeamSOSApi: { - SystemInfo: { - ProtocolVersion: '1.0', - ModelName: 'ARTESCA', - LastModified: '2024-01-01T00:00:00.000Z', + it( + 'should list only available files when only SystemInfo is present, without calling UtilizationService', + async () => { + const bucketMdOnlySystem = { + ...bucketMd, + _capabilities: { + VeeamSOSApi: { + SystemInfo: { + ProtocolVersion: '1.0', + ModelName: 'ARTESCA', + LastModified: '2024-01-01T00:00:00.000Z', + }, }, }, - }, - }; - metadataStub.callsArgWith(2, null, bucketMdOnlySystem); + }; + metadataStub.callsArgWith(2, null, bucketMdOnlySystem); - const request = createRequest(); - const response = createResponse(); + const request = createRequest(); + const response = createResponse(); - listVeeamFiles(request, response, bucketMdOnlySystem, log); + await listVeeamFiles(request, response, bucketMdOnlySystem, log); - giveAsyncCallbackTimeToExecute(() => { + assert(!utilizationStub.called, 'should not call UtilizationService without CapacityInfo'); assert(response.writeHead.calledWith(200), 'should return 200'); assert(response.end.called, 'response should be ended'); - done(); - }); - }); + }, + ); }); - diff --git a/tests/unit/routes/veeam-utils.js b/tests/unit/routes/veeam-utils.js new file mode 100644 index 0000000000..a76ee65f7c --- /dev/null +++ b/tests/unit/routes/veeam-utils.js @@ -0,0 +1,266 @@ +const assert = require('assert'); +const sinon = require('sinon'); +const UtilizationService = require('../../../lib/utilization/instance'); +const metadata = require('../../../lib/metadata/wrapper'); +const { fetchCapacityMetrics, buildVeeamFileData } = require('../../../lib/routes/veeam/utils'); +const { DummyRequestLogger } = require('../helpers'); + +describe('fetchCapacityMetrics', () => { + let utilizationStub; + let log; + let logWarnSpy; + let logErrorSpy; + + const bucketMd = { + _name: 'test-bucket', + _creationDate: '2024-01-01T00:00:00.000Z', + }; + + const request = { + bucketName: 'test-bucket', + }; + + beforeEach(() => { + log = new DummyRequestLogger(); + logWarnSpy = sinon.spy(); + logErrorSpy = sinon.spy(); + log.warn = logWarnSpy; + log.error = logErrorSpy; + + utilizationStub = sinon.stub(UtilizationService, 'getUtilizationMetrics'); + }); + + afterEach(() => { + sinon.restore(); + }); + + it('should call UtilizationService with the correct bucket key', async () => { + utilizationStub.callsArgWith(4, null, {}); + + await fetchCapacityMetrics(bucketMd, request, log); + + const expectedKey = `test-bucket_${new Date('2024-01-01T00:00:00.000Z').getTime()}`; + assert.strictEqual(utilizationStub.getCall(0).args[0], 'bucket'); + assert.strictEqual(utilizationStub.getCall(0).args[1], expectedKey); + }); + + it('should resolve with metrics on success', async () => { + const bucketMetrics = { bytesTotal: 42, date: '2026-03-26T19:00:08.996Z' }; + utilizationStub.callsArgWith(4, null, bucketMetrics); + + const metrics = await fetchCapacityMetrics(bucketMd, request, log); + + assert.strictEqual(metrics, bucketMetrics); + assert(!logWarnSpy.called); + assert(!logErrorSpy.called); + }); + + it('should resolve with no error and a default date on 404', async () => { + const error404 = new Error('Not Found'); + error404.response = { status: 404 }; + utilizationStub.callsArgWith(4, error404); + + const metrics = await fetchCapacityMetrics(bucketMd, request, log); + + assert(metrics && metrics.date instanceof Date, 'metrics should have a Date for date'); + assert(logWarnSpy.calledOnce); + assert(logWarnSpy.getCall(0).args[0].includes('404')); + assert.strictEqual(logWarnSpy.getCall(0).args[1].bucket, 'test-bucket'); + assert(!logErrorSpy.called); + }); + + it('should also handle 404 via statusCode property', async () => { + const error404 = new Error('Not Found'); + error404.statusCode = 404; + utilizationStub.callsArgWith(4, error404); + + const metrics = await fetchCapacityMetrics(bucketMd, request, log); + + assert(metrics && metrics.date instanceof Date, 'metrics should have a Date for date'); + assert(logWarnSpy.calledOnce); + }); + + it('should reject with error on non-404 failures', async () => { + const error500 = new Error('Internal Server Error'); + error500.response = { status: 500 }; + utilizationStub.callsArgWith(4, error500); + + await assert.rejects( + fetchCapacityMetrics(bucketMd, request, log), + err => err === error500, + ); + + assert(logErrorSpy.calledOnce); + assert.strictEqual(logErrorSpy.getCall(0).args[1].bucket, 'test-bucket'); + assert.strictEqual(logErrorSpy.getCall(0).args[1].statusCode, 500); + assert(!logWarnSpy.called); + }); + + it('should reject with error on connection errors', async () => { + const connError = new Error('Connection refused'); + connError.code = 'ECONNREFUSED'; + utilizationStub.callsArgWith(4, connError); + + await assert.rejects( + fetchCapacityMetrics(bucketMd, request, log), + err => err === connError, + ); + + assert(logErrorSpy.calledOnce); + assert.strictEqual(logErrorSpy.getCall(0).args[1].statusCode, 'ECONNREFUSED'); + }); +}); + +describe('buildVeeamFileData', () => { + let utilizationStub; + let metadataStub; + let log; + + const validPath = '.system-d26a9498-cb7c-4a87-a44a-8ae204f5ba6c/'; + + const capacityObjectKey = `${validPath}capacity.xml`; + const systemObjectKey = `${validPath}system.xml`; + + const bucketMd = { + _name: 'test-bucket', + _creationDate: '2024-01-01T00:00:00.000Z', + _capabilities: { + VeeamSOSApi: { + CapacityInfo: { + Capacity: 1099511627776, + Available: 549755813888, + Used: 0, + LastModified: '2024-01-01T00:00:00.000Z', + }, + }, + }, + }; + + const bucketMdWithSystem = { + ...bucketMd, + _capabilities: { + VeeamSOSApi: { + SystemInfo: { + ProtocolVersion: '1.0', + ModelName: 'ARTESCA', + LastModified: '2024-01-01T00:00:00.000Z', + }, + }, + }, + }; + + const createRequest = objectKey => ({ + bucketName: 'test-bucket', + objectKey, + headers: { host: 'test-bucket.s3.amazonaws.com' }, + }); + + beforeEach(() => { + log = new DummyRequestLogger(); + log.warn = sinon.stub(); + log.error = sinon.stub(); + + metadataStub = sinon.stub(metadata, 'getBucket'); + utilizationStub = sinon.stub(UtilizationService, 'getUtilizationMetrics'); + }); + + afterEach(() => { + sinon.restore(); + }); + + it('should reject with InternalError when metadata.getBucket fails', async () => { + metadataStub.callsArgWith(2, new Error('DB error')); + + await assert.rejects( + buildVeeamFileData(createRequest(capacityObjectKey), bucketMd, log), + err => err.code === 500, + ); + }); + + it('should reject with NoSuchKey when capabilities do not include the requested file', async () => { + metadataStub.callsArgWith(2, null, { _capabilities: {} }); + + await assert.rejects( + buildVeeamFileData(createRequest(capacityObjectKey), bucketMd, log), + err => err.code === 404, + ); + }); + + it('should reject with InternalError when fetchCapacityMetrics fails', async () => { + metadataStub.callsArgWith(2, null, bucketMd); + const error500 = new Error('Internal Server Error'); + error500.response = { status: 500 }; + utilizationStub.callsArgWith(4, error500); + + await assert.rejects( + buildVeeamFileData(createRequest(capacityObjectKey), bucketMd, log), + err => err.code === 500, + ); + }); + + it('should build capacity.xml with SUR metrics date and applied Used/Available', async () => { + const metricsDate = new Date('2026-03-26T19:00:08.996Z'); + metadataStub.callsArgWith(2, null, bucketMd); + utilizationStub.callsArgWith(4, null, { date: metricsDate, bytesTotal: 100 }); + + const result = await buildVeeamFileData(createRequest(capacityObjectKey), bucketMd, log); + + assert.strictEqual(result.modified, metricsDate); + assert(result.xmlContent.includes('CapacityInfo')); + assert(result.xmlContent.includes('100')); + assert(result.xmlContent.includes('')); + assert(Buffer.isBuffer(result.dataBuffer)); + assert.deepStrictEqual(result.dataBuffer, Buffer.from(result.xmlContent)); + assert.strictEqual(result.bucketData, bucketMd); + }); + + it('should use current date for capacity.xml when UtilizationService returns 404', async () => { + const before = Date.now(); + metadataStub.callsArgWith(2, null, bucketMd); + const error404 = new Error('Not Found'); + error404.response = { status: 404 }; + utilizationStub.callsArgWith(4, error404); + + const result = await buildVeeamFileData(createRequest(capacityObjectKey), bucketMd, log); + + assert(result.modified instanceof Date); + assert(result.modified.getTime() >= before); + assert(result.xmlContent.includes('CapacityInfo')); + }); + + it('should build system.xml without calling UtilizationService', async () => { + metadataStub.callsArgWith(2, null, bucketMdWithSystem); + const before = Date.now(); + + const result = await buildVeeamFileData(createRequest(systemObjectKey), bucketMdWithSystem, log); + + assert(!utilizationStub.called, 'should not call UtilizationService for system.xml'); + assert(result.modified instanceof Date); + assert(result.modified.getTime() >= before); + assert(result.xmlContent.includes('SystemInfo')); + assert(result.xmlContent.includes('ARTESCA')); + assert(Buffer.isBuffer(result.dataBuffer)); + }); + + it('should not overwrite Used when it is already set', async () => { + const bucketMdWithUsed = { + ...bucketMd, + _capabilities: { + VeeamSOSApi: { + CapacityInfo: { + Capacity: 1000, + Available: 600, + Used: 400, + LastModified: '2024-01-01T00:00:00.000Z', + }, + }, + }, + }; + metadataStub.callsArgWith(2, null, bucketMdWithUsed); + utilizationStub.callsArgWith(4, null, { date: new Date(), bytesTotal: 999 }); + + const result = await buildVeeamFileData(createRequest(capacityObjectKey), bucketMdWithUsed, log); + + assert(result.xmlContent.includes('400'), 'should keep existing Used value'); + }); +});