From 71cef41167453711fa83f282f2719db8fc7c1c0e Mon Sep 17 00:00:00 2001 From: Thomas Flament Date: Tue, 28 Jul 2026 15:01:35 +0200 Subject: [PATCH 1/3] Prettier reformat tests/sur/routeVeeam.js Issue: CLDSRV-962 --- tests/sur/routeVeeam.js | 885 ++++++++++++++++++++++------------------ 1 file changed, 487 insertions(+), 398 deletions(-) diff --git a/tests/sur/routeVeeam.js b/tests/sur/routeVeeam.js index 0a9bf51554..6911d3a0d5 100644 --- a/tests/sur/routeVeeam.js +++ b/tests/sur/routeVeeam.js @@ -2,14 +2,10 @@ const assert = require('assert'); const crypto = require('crypto'); const async = require('async'); const { Scuba: MockScuba } = require('../utilities/mock/Scuba'); -const { - CreateBucketCommand, - DeleteBucketCommand, -} = require('@aws-sdk/client-s3'); +const { CreateBucketCommand, DeleteBucketCommand } = require('@aws-sdk/client-s3'); const { makeRequest } = require('../functional/raw-node/utils/makeRequest'); -const BucketUtility = - require('../functional/aws-node-sdk/lib/utility/bucket-util'); +const BucketUtility = require('../functional/aws-node-sdk/lib/utility/bucket-util'); const ipAddress = process.env.IP ? process.env.IP : '127.0.0.1'; @@ -33,9 +29,7 @@ const testCapacity = ` 0 \n`; -const testCapacityMd5 = crypto.createHash('md5') - .update(testCapacity, 'utf-8') - .digest('hex'); +const testCapacityMd5 = crypto.createHash('md5').update(testCapacity, 'utf-8').digest('hex'); const invalidTestCapacity = ` @@ -44,9 +38,7 @@ const invalidTestCapacity = ` 0 \n`; -const invalidTestCapacityMd5 = crypto.createHash('md5') - .update(invalidTestCapacity, 'utf-8') - .digest('hex'); +const invalidTestCapacityMd5 = crypto.createHash('md5').update(invalidTestCapacity, 'utf-8').digest('hex'); const testSystem = ` @@ -69,9 +61,7 @@ const testSystem = ` \n`; -const testSystemMd5 = crypto.createHash('md5') - .update(testSystem, 'utf-8') - .digest('hex'); +const testSystemMd5 = crypto.createHash('md5').update(testSystem, 'utf-8').digest('hex'); const invalidTestSystem = ` @@ -94,9 +84,7 @@ const invalidTestSystem = ` \n`; -const invalidTestSystemMd5 = crypto.createHash('md5') - .update(testSystem, 'utf-8') - .digest('hex'); +const invalidTestSystemMd5 = crypto.createHash('md5').update(testSystem, 'utf-8').digest('hex'); let bucketUtil; let s3; @@ -117,8 +105,7 @@ let s3; * @return {undefined} - and call callback */ function makeVeeamRequest(params, callback) { - const { method, headers, bucket, objectKey, - authCredentials, requestBody, queryObj } = params; + const { method, headers, bucket, objectKey, authCredentials, requestBody, queryObj } = params; const options = { authCredentials, hostname: ipAddress, @@ -167,8 +154,7 @@ function makeVeeamRequest(params, callback) { describe('veeam PUT routes:', () => { before(done => { - bucketUtil = new BucketUtility( - 'default', { signatureVersion: 'v4' }); + bucketUtil = new BucketUtility('default', { signatureVersion: 'v4' }); s3 = bucketUtil.s3; s3.send(new CreateBucketCommand({ Bucket: TEST_BUCKET })) .then(() => done()) @@ -178,7 +164,8 @@ function makeVeeamRequest(params, callback) { }); }); after(done => { - bucketUtil.empty(TEST_BUCKET) + bucketUtil + .empty(TEST_BUCKET) .then(() => s3.send(new DeleteBucketCommand({ Bucket: TEST_BUCKET }))) .then(() => done()) .catch(done); @@ -188,75 +175,85 @@ function makeVeeamRequest(params, callback) { ['.system-d26a9498-cb7c-4a87-a44a-8ae204f5ba6c/system.xml', testSystem, testSystemMd5], ['.system-d26a9498-cb7c-4a87-a44a-8ae204f5ba6c/capacity.xml', testCapacity, testCapacityMd5], ].forEach(key => { - it(`PUT ${key[0]}`, done => makeVeeamRequest({ - method: 'PUT', - bucket: TEST_BUCKET, - objectKey: key[0], - headers: { - 'content-length': key[1].length, - 'content-md5': key[2], - 'x-scal-canonical-id': testArn, - }, - authCredentials: veeamAuthCredentials, - requestBody: key[1], - }, (err, response) => { - if (err) { - // Return the error, if any - return done(err); - } - assert.strictEqual(response.statusCode, 200); - return done(); - })); + it(`PUT ${key[0]}`, done => + makeVeeamRequest( + { + method: 'PUT', + bucket: TEST_BUCKET, + objectKey: key[0], + headers: { + 'content-length': key[1].length, + 'content-md5': key[2], + 'x-scal-canonical-id': testArn, + }, + authCredentials: veeamAuthCredentials, + requestBody: key[1], + }, + (err, response) => { + if (err) { + // Return the error, if any + return done(err); + } + assert.strictEqual(response.statusCode, 200); + return done(); + }, + )); }); [ ['.system-d26a9498-cb7c-4a87-a44a-8ae204f5ba6c/system.xml', invalidTestSystem, invalidTestSystemMd5], ['.system-d26a9498-cb7c-4a87-a44a-8ae204f5ba6c/capacity.xml', invalidTestCapacity, invalidTestCapacityMd5], ].forEach(key => { - it(`PUT ${key[0]} should fail for invalid XML`, done => makeVeeamRequest({ - method: 'PUT', - bucket: TEST_BUCKET, - objectKey: key[0], - headers: { - 'content-length': key[1].length + 3, - 'content-md5': key[2], - 'x-scal-canonical-id': testArn, - }, - authCredentials: veeamAuthCredentials, - requestBody: `${key[1]}gff`, - }, err => { - assert.strictEqual(err.code, 'MalformedXML'); - return done(); - })); + it(`PUT ${key[0]} should fail for invalid XML`, done => + makeVeeamRequest( + { + method: 'PUT', + bucket: TEST_BUCKET, + objectKey: key[0], + headers: { + 'content-length': key[1].length + 3, + 'content-md5': key[2], + 'x-scal-canonical-id': testArn, + }, + authCredentials: veeamAuthCredentials, + requestBody: `${key[1]}gff`, + }, + err => { + assert.strictEqual(err.code, 'MalformedXML'); + return done(); + }, + )); }); [ ['.system-d26a9498-cb7c-4a87-a44a-8ae204f5ba6c/system.xml', testSystem, testSystemMd5], ['.system-d26a9498-cb7c-4a87-a44a-8ae204f5ba6c/capacity.xml', testCapacity, testCapacityMd5], ].forEach(key => { - it(`PUT ${key[0]} should fail if invalid credentials are sent`, done => makeVeeamRequest({ - method: 'PUT', - bucket: TEST_BUCKET, - objectKey: key[0], - headers: { - 'content-length': key[1].length + 3, - 'content-md5': key[2], - 'x-scal-canonical-id': testArn, - }, - authCredentials: badVeeamAuthCredentials, - requestBody: `${key[1]}gff`, - }, err => { - assert.strictEqual(err.code, 'InvalidAccessKeyId'); - return done(); - })); + it(`PUT ${key[0]} should fail if invalid credentials are sent`, done => + makeVeeamRequest( + { + method: 'PUT', + bucket: TEST_BUCKET, + objectKey: key[0], + headers: { + 'content-length': key[1].length + 3, + 'content-md5': key[2], + 'x-scal-canonical-id': testArn, + }, + authCredentials: badVeeamAuthCredentials, + requestBody: `${key[1]}gff`, + }, + err => { + assert.strictEqual(err.code, 'InvalidAccessKeyId'); + return done(); + }, + )); }); }); - describe('veeam GET routes:', () => { beforeEach(done => { - bucketUtil = new BucketUtility( - 'default', { signatureVersion: 'v4' }); + bucketUtil = new BucketUtility('default', { signatureVersion: 'v4' }); s3 = bucketUtil.s3; s3.send(new CreateBucketCommand({ Bucket: TEST_BUCKET })) .then(() => done()) @@ -266,7 +263,8 @@ function makeVeeamRequest(params, callback) { }); }); afterEach(done => { - bucketUtil.empty(TEST_BUCKET) + bucketUtil + .empty(TEST_BUCKET) .then(() => s3.send(new DeleteBucketCommand({ Bucket: TEST_BUCKET }))) .then(() => done()) .catch(done); @@ -278,45 +276,56 @@ function makeVeeamRequest(params, callback) { ].forEach(key => { it(`GET ${key[0]} should return the expected XML file`, done => { scuba.incrementBytesForBucket(TEST_BUCKET, 0); - async.waterfall([ - next => makeVeeamRequest({ - method: 'PUT', - bucket: TEST_BUCKET, - objectKey: key[0], - headers: { - 'content-length': key[1].length, - 'content-md5': key[2], - 'x-scal-canonical-id': testArn, - }, - authCredentials: veeamAuthCredentials, - requestBody: key[1], - }, (err, response) => { - if (err) { - return done(err); - } - assert.strictEqual(response.statusCode, 200); - return next(); - }), - next => makeVeeamRequest({ - method: 'GET', - bucket: TEST_BUCKET, - objectKey: key[0], - headers: { - 'x-scal-canonical-id': testArn, - }, - authCredentials: veeamAuthCredentials, - }, (err, response) => { - if (err) { - return done(err); - } - assert.strictEqual(response.statusCode, 200); - assert.strictEqual(response.body.replaceAll(' ', ''), key[1].replaceAll(' ', '')); - return next(); - }), - ], err => { - assert.ifError(err); - return done(); - }); + async.waterfall( + [ + next => + makeVeeamRequest( + { + method: 'PUT', + bucket: TEST_BUCKET, + objectKey: key[0], + headers: { + 'content-length': key[1].length, + 'content-md5': key[2], + 'x-scal-canonical-id': testArn, + }, + authCredentials: veeamAuthCredentials, + requestBody: key[1], + }, + (err, response) => { + if (err) { + return done(err); + } + assert.strictEqual(response.statusCode, 200); + return next(); + }, + ), + next => + makeVeeamRequest( + { + method: 'GET', + bucket: TEST_BUCKET, + objectKey: key[0], + headers: { + 'x-scal-canonical-id': testArn, + }, + authCredentials: veeamAuthCredentials, + }, + (err, response) => { + if (err) { + return done(err); + } + assert.strictEqual(response.statusCode, 200); + assert.strictEqual(response.body.replaceAll(' ', ''), key[1].replaceAll(' ', '')); + return next(); + }, + ), + ], + err => { + assert.ifError(err); + return done(); + }, + ); }); }); @@ -325,179 +334,221 @@ function makeVeeamRequest(params, callback) { ['.system-d26a9498-cb7c-4a87-a44a-8ae204f5ba6c/capacity.xml', testCapacity, testCapacityMd5], ].forEach(key => { it(`GET ${key[0]} should return the expected XML file for cors requests`, done => { - async.waterfall([ - next => makeVeeamRequest({ - method: 'PUT', - bucket: TEST_BUCKET, - objectKey: key[0], - headers: { - 'origin': 'http://localhost:8000', - 'content-length': key[1].length, - 'content-md5': key[2], - 'x-scal-canonical-id': testArn, - }, - authCredentials: veeamAuthCredentials, - requestBody: key[1], - }, (err, response) => { - if (err) { - return done(err); - } - assert.strictEqual(response.statusCode, 200); - return next(); - }), - next => makeVeeamRequest({ - method: 'GET', - bucket: TEST_BUCKET, - objectKey: key[0], - headers: { - 'origin': 'http://localhost:8000', - 'x-scal-canonical-id': testArn, - }, - authCredentials: veeamAuthCredentials, - }, (err, response) => { - if (err) { - return done(err); - } - assert.strictEqual(response.statusCode, 200); - assert.strictEqual(response.body.replaceAll(' ', ''), key[1].replaceAll(' ', '')); - return next(); - }), - ], err => { - assert.ifError(err); - return done(); - }); + async.waterfall( + [ + next => + makeVeeamRequest( + { + method: 'PUT', + bucket: TEST_BUCKET, + objectKey: key[0], + headers: { + origin: 'http://localhost:8000', + 'content-length': key[1].length, + 'content-md5': key[2], + 'x-scal-canonical-id': testArn, + }, + authCredentials: veeamAuthCredentials, + requestBody: key[1], + }, + (err, response) => { + if (err) { + return done(err); + } + assert.strictEqual(response.statusCode, 200); + return next(); + }, + ), + next => + makeVeeamRequest( + { + method: 'GET', + bucket: TEST_BUCKET, + objectKey: key[0], + headers: { + origin: 'http://localhost:8000', + 'x-scal-canonical-id': testArn, + }, + authCredentials: veeamAuthCredentials, + }, + (err, response) => { + if (err) { + return done(err); + } + assert.strictEqual(response.statusCode, 200); + assert.strictEqual(response.body.replaceAll(' ', ''), key[1].replaceAll(' ', '')); + return next(); + }, + ), + ], + err => { + assert.ifError(err); + return done(); + }, + ); }); }); - [ ['.system-d26a9498-cb7c-4a87-a44a-8ae204f5ba6c/system.xml', testSystem, testSystemMd5], ['.system-d26a9498-cb7c-4a87-a44a-8ae204f5ba6c/capacity.xml', testCapacity, testCapacityMd5], ].forEach(key => { - it(`GET ${key[0]} should fail if no data in bucket metadata`, done => makeVeeamRequest({ - method: 'GET', - bucket: TEST_BUCKET, - objectKey: key[0], - headers: { - 'x-scal-canonical-id': testArn, - }, - authCredentials: veeamAuthCredentials, - }, err => { - assert.strictEqual(err.code, 'NoSuchKey'); - return done(); - })); + it(`GET ${key[0]} should fail if no data in bucket metadata`, done => + makeVeeamRequest( + { + method: 'GET', + bucket: TEST_BUCKET, + objectKey: key[0], + headers: { + 'x-scal-canonical-id': testArn, + }, + authCredentials: veeamAuthCredentials, + }, + err => { + assert.strictEqual(err.code, 'NoSuchKey'); + return done(); + }, + )); }); it('GET capacity.xml should return 200 when scubaclient returns 404 (post-install scenario)', done => { // This test simulates the post-install scenario where scubaclient returns 404 // because no metrics are available yet. By not calling scuba.incrementBytesForBucket, // the mock scuba server will return 404 for this bucket. - - async.waterfall([ - next => makeVeeamRequest({ - method: 'PUT', - bucket: TEST_BUCKET, - objectKey: '.system-d26a9498-cb7c-4a87-a44a-8ae204f5ba6c/capacity.xml', - headers: { - 'content-length': testCapacity.length, - 'content-md5': testCapacityMd5, - 'x-scal-canonical-id': testArn, - }, - authCredentials: veeamAuthCredentials, - requestBody: testCapacity, - }, (err, response) => { - if (err) { - return done(err); - } - assert.strictEqual(response.statusCode, 200); - return next(); - }), - next => makeVeeamRequest({ - method: 'GET', - bucket: TEST_BUCKET, - objectKey: '.system-d26a9498-cb7c-4a87-a44a-8ae204f5ba6c/capacity.xml', - headers: { - 'x-scal-canonical-id': testArn, - }, - authCredentials: veeamAuthCredentials, - }, (err, response) => { - if (err) { - return done(err); - } - // Critical assertion: for 404 from scubaclient (no metrics yet), - // should return 200 with static capacity data (Used=0) - assert.strictEqual(response.statusCode, 200, - 'should return 200 when scubaclient returns 404 (no metrics available)'); - // Should return capacity.xml with static data - assert(response.body.includes(''), - 'should return capacity.xml content'); - assert(response.body.includes('0'), - 'Used should be 0 from static bucket metadata'); - return next(); - }), - ], err => { - assert.ifError(err); - return done(); - }); + + async.waterfall( + [ + next => + makeVeeamRequest( + { + method: 'PUT', + bucket: TEST_BUCKET, + objectKey: '.system-d26a9498-cb7c-4a87-a44a-8ae204f5ba6c/capacity.xml', + headers: { + 'content-length': testCapacity.length, + 'content-md5': testCapacityMd5, + 'x-scal-canonical-id': testArn, + }, + authCredentials: veeamAuthCredentials, + requestBody: testCapacity, + }, + (err, response) => { + if (err) { + return done(err); + } + assert.strictEqual(response.statusCode, 200); + return next(); + }, + ), + next => + makeVeeamRequest( + { + method: 'GET', + bucket: TEST_BUCKET, + objectKey: '.system-d26a9498-cb7c-4a87-a44a-8ae204f5ba6c/capacity.xml', + headers: { + 'x-scal-canonical-id': testArn, + }, + authCredentials: veeamAuthCredentials, + }, + (err, response) => { + if (err) { + return done(err); + } + // Critical assertion: for 404 from scubaclient (no metrics yet), + // should return 200 with static capacity data (Used=0) + assert.strictEqual( + response.statusCode, + 200, + 'should return 200 when scubaclient returns 404 (no metrics available)', + ); + // Should return capacity.xml with static data + assert(response.body.includes(''), 'should return capacity.xml content'); + assert( + response.body.includes('0'), + 'Used should be 0 from static bucket metadata', + ); + return next(); + }, + ), + ], + err => { + assert.ifError(err); + return done(); + }, + ); }); it('GET system.xml should return 200 even when scubaclient is down', done => { // system.xml doesn't use scubaclient, so it should always work // This test stops scuba to verify system.xml is independent of utilization metrics - async.waterfall([ - next => makeVeeamRequest({ - method: 'PUT', - bucket: TEST_BUCKET, - objectKey: '.system-d26a9498-cb7c-4a87-a44a-8ae204f5ba6c/system.xml', - headers: { - 'content-length': testSystem.length, - 'content-md5': testSystemMd5, - 'x-scal-canonical-id': testArn, + async.waterfall( + [ + next => + makeVeeamRequest( + { + method: 'PUT', + bucket: TEST_BUCKET, + objectKey: '.system-d26a9498-cb7c-4a87-a44a-8ae204f5ba6c/system.xml', + headers: { + 'content-length': testSystem.length, + 'content-md5': testSystemMd5, + 'x-scal-canonical-id': testArn, + }, + authCredentials: veeamAuthCredentials, + requestBody: testSystem, + }, + (err, response) => { + if (err) { + return done(err); + } + assert.strictEqual(response.statusCode, 200); + return next(); + }, + ), + next => { + // Stop scuba - system.xml should still work + scuba.stop(); + return next(); }, - authCredentials: veeamAuthCredentials, - requestBody: testSystem, - }, (err, response) => { - if (err) { - return done(err); - } - assert.strictEqual(response.statusCode, 200); - return next(); - }), - next => { - // Stop scuba - system.xml should still work - scuba.stop(); - return next(); + next => + makeVeeamRequest( + { + method: 'GET', + bucket: TEST_BUCKET, + objectKey: '.system-d26a9498-cb7c-4a87-a44a-8ae204f5ba6c/system.xml', + headers: { + 'x-scal-canonical-id': testArn, + }, + authCredentials: veeamAuthCredentials, + }, + (err, response) => { + if (err) { + return done(err); + } + assert.strictEqual( + response.statusCode, + 200, + 'system.xml should always return 200 even when scuba is down', + ); + assert.strictEqual(response.body.replaceAll(' ', ''), testSystem.replaceAll(' ', '')); + return next(); + }, + ), + ], + err => { + // Restart scuba for subsequent tests + scuba.start(); + assert.ifError(err); + return done(); }, - next => makeVeeamRequest({ - method: 'GET', - bucket: TEST_BUCKET, - objectKey: '.system-d26a9498-cb7c-4a87-a44a-8ae204f5ba6c/system.xml', - headers: { - 'x-scal-canonical-id': testArn, - }, - authCredentials: veeamAuthCredentials, - }, (err, response) => { - if (err) { - return done(err); - } - assert.strictEqual(response.statusCode, 200, - 'system.xml should always return 200 even when scuba is down'); - assert.strictEqual(response.body.replaceAll(' ', ''), testSystem.replaceAll(' ', '')); - return next(); - }), - ], err => { - // Restart scuba for subsequent tests - scuba.start(); - assert.ifError(err); - return done(); - }); + ); }); }); describe('veeam DELETE routes:', () => { beforeEach(done => { - bucketUtil = new BucketUtility( - 'default', { signatureVersion: 'v4' }); + bucketUtil = new BucketUtility('default', { signatureVersion: 'v4' }); s3 = bucketUtil.s3; s3.send(new CreateBucketCommand({ Bucket: TEST_BUCKET })) .then(() => done()) @@ -507,7 +558,8 @@ function makeVeeamRequest(params, callback) { }); }); afterEach(done => { - bucketUtil.empty(TEST_BUCKET) + bucketUtil + .empty(TEST_BUCKET) .then(() => s3.send(new DeleteBucketCommand({ Bucket: TEST_BUCKET }))) .then(() => done()) .catch(done); @@ -518,42 +570,101 @@ function makeVeeamRequest(params, callback) { ['.system-d26a9498-cb7c-4a87-a44a-8ae204f5ba6c/capacity.xml', testCapacity, testCapacityMd5], ].forEach(key => { it(`DELETE ${key[0]} should delete the XML file`, done => { - async.waterfall([ - next => makeVeeamRequest({ - method: 'PUT', - bucket: TEST_BUCKET, - objectKey: key[0], - headers: { - 'content-length': key[1].length, - 'content-md5': key[2], - 'x-scal-canonical-id': testArn, - }, - authCredentials: veeamAuthCredentials, - requestBody: key[1], - }, (err, response) => { - if (err) { - return done(err); - } - assert.strictEqual(response.statusCode, 200); - return next(); - }), - next => makeVeeamRequest({ - method: 'GET', - bucket: TEST_BUCKET, - objectKey: key[0], - headers: { - 'x-scal-canonical-id': testArn, - }, - authCredentials: veeamAuthCredentials, - }, (err, response) => { - if (err) { - return done(err); - } - assert.strictEqual(response.statusCode, 200); - assert.strictEqual(response.body.replaceAll(' ', ''), key[1].replaceAll(' ', '')); - return next(); - }), - next => makeVeeamRequest({ + async.waterfall( + [ + next => + makeVeeamRequest( + { + method: 'PUT', + bucket: TEST_BUCKET, + objectKey: key[0], + headers: { + 'content-length': key[1].length, + 'content-md5': key[2], + 'x-scal-canonical-id': testArn, + }, + authCredentials: veeamAuthCredentials, + requestBody: key[1], + }, + (err, response) => { + if (err) { + return done(err); + } + assert.strictEqual(response.statusCode, 200); + return next(); + }, + ), + next => + makeVeeamRequest( + { + method: 'GET', + bucket: TEST_BUCKET, + objectKey: key[0], + headers: { + 'x-scal-canonical-id': testArn, + }, + authCredentials: veeamAuthCredentials, + }, + (err, response) => { + if (err) { + return done(err); + } + assert.strictEqual(response.statusCode, 200); + assert.strictEqual(response.body.replaceAll(' ', ''), key[1].replaceAll(' ', '')); + return next(); + }, + ), + next => + makeVeeamRequest( + { + method: 'DELETE', + bucket: TEST_BUCKET, + objectKey: key[0], + headers: { + 'x-scal-canonical-id': testArn, + }, + authCredentials: veeamAuthCredentials, + }, + (err, response) => { + if (err) { + return done(err); + } + assert.strictEqual(response.statusCode, 204); + return next(); + }, + ), + next => + makeVeeamRequest( + { + method: 'GET', + bucket: TEST_BUCKET, + objectKey: key[0], + headers: { + 'x-scal-canonical-id': testArn, + }, + authCredentials: veeamAuthCredentials, + }, + err => { + assert.strictEqual(err.code, 'NoSuchKey'); + return next(); + }, + ), + ], + err => { + assert.ifError(err); + return done(); + }, + ); + }); + }); + + [ + ['.system-d26a9498-cb7c-4a87-a44a-8ae204f5ba6c/system.xml', testSystem, testSystemMd5], + ['.system-d26a9498-cb7c-4a87-a44a-8ae204f5ba6c/capacity.xml', testCapacity, testCapacityMd5], + ].forEach(key => { + it(`DELETE ${key[0]} should fail if XML doesn't exist yet`, done => + makeVeeamRequest( + { method: 'DELETE', bucket: TEST_BUCKET, objectKey: key[0], @@ -561,55 +672,18 @@ function makeVeeamRequest(params, callback) { 'x-scal-canonical-id': testArn, }, authCredentials: veeamAuthCredentials, - }, (err, response) => { - if (err) { - return done(err); - } - assert.strictEqual(response.statusCode, 204); - return next(); - }), - next => makeVeeamRequest({ - method: 'GET', - bucket: TEST_BUCKET, - objectKey: key[0], - headers: { - 'x-scal-canonical-id': testArn, - }, - authCredentials: veeamAuthCredentials, - }, err => { + }, + err => { assert.strictEqual(err.code, 'NoSuchKey'); - return next(); - }), - ], err => { - assert.ifError(err); - return done(); - }); - }); - }); - - [ - ['.system-d26a9498-cb7c-4a87-a44a-8ae204f5ba6c/system.xml', testSystem, testSystemMd5], - ['.system-d26a9498-cb7c-4a87-a44a-8ae204f5ba6c/capacity.xml', testCapacity, testCapacityMd5], - ].forEach(key => { - it(`DELETE ${key[0]} should fail if XML doesn't exist yet`, done => makeVeeamRequest({ - method: 'DELETE', - bucket: TEST_BUCKET, - objectKey: key[0], - headers: { - 'x-scal-canonical-id': testArn, - }, - authCredentials: veeamAuthCredentials, - }, err => { - assert.strictEqual(err.code, 'NoSuchKey'); - return done(); - })); + return done(); + }, + )); }); }); describe('veeam HEAD routes:', () => { beforeEach(done => { - bucketUtil = new BucketUtility( - 'default', { signatureVersion: 'v4' }); + bucketUtil = new BucketUtility('default', { signatureVersion: 'v4' }); s3 = bucketUtil.s3; s3.send(new CreateBucketCommand({ Bucket: TEST_BUCKET })) .then(() => done()) @@ -619,7 +693,8 @@ function makeVeeamRequest(params, callback) { }); }); afterEach(done => { - bucketUtil.empty(TEST_BUCKET) + bucketUtil + .empty(TEST_BUCKET) .then(() => s3.send(new DeleteBucketCommand({ Bucket: TEST_BUCKET }))) .then(() => done()) .catch(done); @@ -630,44 +705,55 @@ function makeVeeamRequest(params, callback) { ['.system-d26a9498-cb7c-4a87-a44a-8ae204f5ba6c/capacity.xml', testCapacity, testCapacityMd5], ].forEach(key => { it(`HEAD ${key[0]} should return the existing XML file metadata`, done => { - async.waterfall([ - next => makeVeeamRequest({ - method: 'PUT', - bucket: TEST_BUCKET, - objectKey: key[0], - headers: { - 'content-length': key[1].length, - 'content-md5': key[2], - 'x-scal-canonical-id': testArn, - }, - authCredentials: veeamAuthCredentials, - requestBody: key[1], - }, (err, response) => { - if (err) { - return done(err); - } - assert.strictEqual(response.statusCode, 200); - return next(); - }), - next => makeVeeamRequest({ - method: 'HEAD', - bucket: TEST_BUCKET, - objectKey: key[0], - headers: { - 'x-scal-canonical-id': testArn, - }, - authCredentials: veeamAuthCredentials, - }, (err, response) => { - if (err) { - return done(err); - } - assert.strictEqual(response.statusCode, 200); - return next(); - }), - ], err => { - assert.ifError(err); - return done(); - }); + async.waterfall( + [ + next => + makeVeeamRequest( + { + method: 'PUT', + bucket: TEST_BUCKET, + objectKey: key[0], + headers: { + 'content-length': key[1].length, + 'content-md5': key[2], + 'x-scal-canonical-id': testArn, + }, + authCredentials: veeamAuthCredentials, + requestBody: key[1], + }, + (err, response) => { + if (err) { + return done(err); + } + assert.strictEqual(response.statusCode, 200); + return next(); + }, + ), + next => + makeVeeamRequest( + { + method: 'HEAD', + bucket: TEST_BUCKET, + objectKey: key[0], + headers: { + 'x-scal-canonical-id': testArn, + }, + authCredentials: veeamAuthCredentials, + }, + (err, response) => { + if (err) { + return done(err); + } + assert.strictEqual(response.statusCode, 200); + return next(); + }, + ), + ], + err => { + assert.ifError(err); + return done(); + }, + ); }); }); @@ -675,28 +761,30 @@ function makeVeeamRequest(params, callback) { ['.system-d26a9498-cb7c-4a87-a44a-8ae204f5ba6c/system.xml', testSystem, testSystemMd5], ['.system-d26a9498-cb7c-4a87-a44a-8ae204f5ba6c/capacity.xml', testCapacity, testCapacityMd5], ].forEach(key => { - it(`HEAD ${key[0]} should fail if no data in bucket metadata`, done => makeVeeamRequest({ - method: 'HEAD', - bucket: TEST_BUCKET, - objectKey: key[0], - headers: { - 'x-scal-canonical-id': testArn, - }, - authCredentials: veeamAuthCredentials, - }, (err, res) => { - assert.strictEqual(res.statusCode, 404); - return done(); - })); + it(`HEAD ${key[0]} should fail if no data in bucket metadata`, done => + makeVeeamRequest( + { + method: 'HEAD', + bucket: TEST_BUCKET, + objectKey: key[0], + headers: { + 'x-scal-canonical-id': testArn, + }, + authCredentials: veeamAuthCredentials, + }, + (err, res) => { + assert.strictEqual(res.statusCode, 404); + return done(); + }, + )); }); }); }); - // TODO {test_debt} handle query params tests with signature (happy path) describe.skip('veeam LIST routes:', () => { beforeEach(done => { - bucketUtil = new BucketUtility( - 'default', { signatureVersion: 'v4' }); + bucketUtil = new BucketUtility('default', { signatureVersion: 'v4' }); s3 = bucketUtil.s3; s3.send(new CreateBucketCommand({ Bucket: TEST_BUCKET })) .then(() => done()) @@ -706,7 +794,8 @@ describe.skip('veeam LIST routes:', () => { }); }); afterEach(done => { - bucketUtil.empty(TEST_BUCKET) + bucketUtil + .empty(TEST_BUCKET) .then(() => s3.send(new DeleteBucketCommand({ Bucket: TEST_BUCKET }))) .then(() => done()) .catch(done); From 8021adddb680add8c915d16efd13341e36c6a60a Mon Sep 17 00:00:00 2001 From: Thomas Flament Date: Tue, 28 Jul 2026 15:44:01 +0200 Subject: [PATCH 2/3] Support streaming uploads on the Veeam SOSAPI route MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The route prepared its request stream with arsenal's s3middleware/prepareStream, which only decodes STREAMING-AWS4-HMAC-SHA256-PAYLOAD — and was called with misaligned arguments (vault receiving the logger, log the callback), breaking that case too. Unsigned streaming uploads with a trailing checksum (STREAMING-UNSIGNED-PAYLOAD-TRAILER), the default for aws CLI >= 2.23 and current AWS SDKs, went through undecoded: the aws-chunked framing exceeded the announced decoded length and the route replied 500 InternalError ("data stream exceed announced size"). Switch receiveData() to the checksum-aware object-path prepareStream(): both streaming encodings are now decoded, and x-amz-checksum-* header and trailing checksums are validated with the same semantics as PutObject. Also return InvalidRequest (400) instead of InternalError when the body exceeds the announced content-length, as this indicates a malformed client request. Issue: CLDSRV-962 --- lib/routes/veeam/utils.js | 46 +++++++++-- tests/sur/routeVeeam.js | 96 +++++++++++++++++++++++ tests/unit/routes/veeam-utils.js | 130 ++++++++++++++++++++++++++++++- 3 files changed, 266 insertions(+), 6 deletions(-) diff --git a/lib/routes/veeam/utils.js b/lib/routes/veeam/utils.js index 7819b744e4..567cd347b9 100644 --- a/lib/routes/veeam/utils.js +++ b/lib/routes/veeam/utils.js @@ -5,7 +5,12 @@ 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 { prepareStream } = require('../../api/apiUtils/object/prepareStream'); +const { + getChecksumDataFromHeaders, + arsenalErrorFromChecksumError, + defaultChecksumData, +} = require('../../api/apiUtils/integrity/validateChecksums'); const UtilizationService = require('../../utilization/instance'); const metadata = require('../../metadata/wrapper'); @@ -25,6 +30,10 @@ function _decodeURI(uri) { /** * Generic function to get data from a client request. * + * The request stream is decoded and validated according to its + * x-amz-content-sha256 value (plain, signed streaming or unsigned streaming + * with trailing checksum), with the same semantics as the object data path. + * * @param {object} request - incoming request * @param {object} log - logger object * @returns {Promise} @@ -40,6 +49,11 @@ async function receiveData(request, log) { `maximum allowed content-length is ${ContentLengthThreshold} bytes`, ); } + const headerChecksum = getChecksumDataFromHeaders(request.headers); + if (headerChecksum && headerChecksum.error) { + throw arsenalErrorFromChecksumError(headerChecksum); + } + const checksums = { primary: headerChecksum || defaultChecksumData, secondary: null }; return await new Promise((resolve, reject) => { const settle = jsutil.once((err, result) => { if (err) { @@ -54,18 +68,40 @@ async function receiveData(request, log) { totalLength += chunk.length; if (totalLength > parsedContentLength) { log.error('data stream exceed announced size', { parsedContentLength, overflow: totalLength }); - return cb(errors.InternalError); + return cb( + errorInstances.InvalidRequest.customizeDescription( + 'request body exceeds the announced content-length', + ), + ); } 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)); + const prepared = prepareStream(request, request.streamingV4Params, checksums, log, settle); + if (prepared.error) { + settle(prepared.error); + return; + } + pipeline(prepared.stream, collector).then( + () => { + // Checksum transforms only compute digests while streaming: + // validation against the expected values (header or trailer) + // must be done once the stream is fully consumed. + const checksumErr = + (prepared.contentSHA256Stream && prepared.contentSHA256Stream.validateChecksum()) || + prepared.stream.validateChecksum(); + if (checksumErr) { + log.debug('failed checksum validation', { error: checksumErr }); + return settle(arsenalErrorFromChecksumError(checksumErr)); + } + return settle(null, Buffer.concat(chunks).toString()); + }, + err => settle(err), + ); }); } diff --git a/tests/sur/routeVeeam.js b/tests/sur/routeVeeam.js index 6911d3a0d5..ac073a7177 100644 --- a/tests/sur/routeVeeam.js +++ b/tests/sur/routeVeeam.js @@ -5,10 +5,22 @@ const { Scuba: MockScuba } = require('../utilities/mock/Scuba'); const { CreateBucketCommand, DeleteBucketCommand } = require('@aws-sdk/client-s3'); const { makeRequest } = require('../functional/raw-node/utils/makeRequest'); +const HttpRequestAuthV4 = require('../functional/raw-node/utils/HttpRequestAuthV4'); const BucketUtility = require('../functional/aws-node-sdk/lib/utility/bucket-util'); +const { algorithms } = require('../../lib/api/apiUtils/integrity/validateChecksums'); const ipAddress = process.env.IP ? process.env.IP : '127.0.0.1'; +// The Veeam route strips the internal '/_/veeam' routing prefix from the URL +// before verifying the V4 signature, so requests must be signed on the +// un-prefixed path while being sent to the prefixed one. +class VeeamHttpRequestAuthV4 extends HttpRequestAuthV4 { + getCanonicalRequest(urlObj, signedHeaders, contentSha256) { + const signUrlObj = new URL(urlObj.href.replace('/_/veeam', '')); + return super.getCanonicalRequest(signUrlObj, signedHeaders, contentSha256); + } +} + const veeamAuthCredentials = { accessKey: 'accessKey1', secretKey: 'verySecretKey1', @@ -249,6 +261,90 @@ function makeVeeamRequest(params, callback) { }, )); }); + + describe('streaming uploads with trailing checksum:', () => { + const capacityKey = '.system-d26a9498-cb7c-4a87-a44a-8ae204f5ba6c/capacity.xml'; + let trailerDigest; + + const buildChunkedBody = digest => + `${testCapacity.length.toString(16)}\r\n${testCapacity}\r\n` + + `0\r\nx-amz-checksum-crc64nvme:${digest}\r\n\r\n`; + + const makeStreamingVeeamRequest = (digest, done, callback) => { + const requestBody = buildChunkedBody(digest); + const req = new VeeamHttpRequestAuthV4( + `http://${ipAddress}:8000/_/veeam/${TEST_BUCKET}/${capacityKey}`, + Object.assign( + { + method: 'PUT', + headers: { + 'content-length': requestBody.length, + 'x-amz-decoded-content-length': testCapacity.length, + 'x-amz-content-sha256': 'STREAMING-UNSIGNED-PAYLOAD-TRAILER', + 'x-amz-trailer': 'x-amz-checksum-crc64nvme', + 'x-scal-canonical-id': testArn, + }, + }, + veeamAuthCredentials, + ), + res => callback(res), + ); + req.on('error', done); + req.end(requestBody); + }; + + before(async () => { + trailerDigest = await algorithms.crc64nvme.digest(Buffer.from(testCapacity)); + }); + + it('should PUT capacity.xml with unsigned trailing checksum', done => { + makeStreamingVeeamRequest(trailerDigest, done, res => { + assert.strictEqual(res.statusCode, 200); + res.on('data', () => {}); + res.on('end', done); + }); + }); + + it('should return BadDigest for PUT capacity.xml with a wrong trailing checksum', done => { + makeStreamingVeeamRequest('AAAAAAAAAAA=', done, res => { + assert.strictEqual(res.statusCode, 400); + const chunks = []; + res.on('data', chunk => chunks.push(chunk)); + res.on('end', () => { + assert.match(chunks.join(''), /BadDigest/); + done(); + }); + }); + }); + + // Without a pre-set x-amz-content-sha256 header, the request + // helper switches to signed streaming + // (STREAMING-AWS4-HMAC-SHA256-PAYLOAD) and signs each chunk. + it('should PUT capacity.xml with signed streaming (aws-chunked)', done => { + const req = new VeeamHttpRequestAuthV4( + `http://${ipAddress}:8000/_/veeam/${TEST_BUCKET}/${capacityKey}`, + Object.assign( + { + method: 'PUT', + headers: { + 'content-length': testCapacity.length, + 'x-scal-canonical-id': testArn, + }, + }, + veeamAuthCredentials, + ), + res => { + assert.strictEqual(res.statusCode, 200); + res.on('data', () => {}); + res.on('end', done); + }, + ); + req.on('error', done); + // end() (rather than write() alone) is needed to send the + // terminating zero-length signed chunk once 'finish' fires. + req.end(testCapacity); + }); + }); }); describe('veeam GET routes:', () => { diff --git a/tests/unit/routes/veeam-utils.js b/tests/unit/routes/veeam-utils.js index eb69569d11..6e31dd5035 100644 --- a/tests/unit/routes/veeam-utils.js +++ b/tests/unit/routes/veeam-utils.js @@ -1,8 +1,10 @@ const assert = require('assert'); +const crypto = require('crypto'); const sinon = require('sinon'); +const { Readable } = require('stream'); const UtilizationService = require('../../../lib/utilization/instance'); const metadata = require('../../../lib/metadata/wrapper'); -const { fetchCapacityMetrics, buildVeeamFileData } = require('../../../lib/routes/veeam/utils'); +const { fetchCapacityMetrics, buildVeeamFileData, receiveData } = require('../../../lib/routes/veeam/utils'); const { DummyRequestLogger } = require('../helpers'); describe('fetchCapacityMetrics', () => { @@ -258,3 +260,129 @@ describe('buildVeeamFileData', () => { assert(result.xmlContent.includes('400'), 'should keep existing Used value'); }); }); + +describe('receiveData', () => { + let log; + + const payload = '0123456789abcdef0123456789abcdef'; + const payloadSha256 = crypto.createHash('sha256').update(payload).digest('base64'); + // crc64nvme of the payload, base64-encoded (same value as in the + // functional trailing checksum tests) + const payloadCrc64 = 'skQv82y5rgE='; + const chunkedBody = digest => + '10\r\n0123456789abcdef\r\n' + '10\r\n0123456789abcdef\r\n' + `0\r\nx-amz-checksum-crc64nvme:${digest}\r\n\r\n`; + + const makeRequest = (body, headers, parsedContentLength, streamingV4Params) => { + const request = new Readable({ read() {} }); + request.headers = headers; + request.parsedContentLength = parsedContentLength; + request.streamingV4Params = streamingV4Params; + process.nextTick(() => { + request.push(Buffer.from(body)); + request.push(null); + }); + return request; + }; + + beforeEach(() => { + log = new DummyRequestLogger(); + }); + + it('should return the body of a plain request', async () => { + const request = makeRequest(payload, { 'x-amz-content-sha256': 'UNSIGNED-PAYLOAD' }, payload.length); + const data = await receiveData(request, log); + assert.strictEqual(data, payload); + }); + + it('should validate a matching x-amz-checksum header', async () => { + const request = makeRequest( + payload, + { + 'x-amz-content-sha256': 'UNSIGNED-PAYLOAD', + 'x-amz-checksum-sha256': payloadSha256, + }, + payload.length, + ); + const data = await receiveData(request, log); + assert.strictEqual(data, payload); + }); + + it('should return BadDigest on x-amz-checksum header mismatch', async () => { + const request = makeRequest( + payload, + { + 'x-amz-content-sha256': 'UNSIGNED-PAYLOAD', + 'x-amz-checksum-sha256': crypto.createHash('sha256').update('other').digest('base64'), + }, + payload.length, + ); + await assert.rejects(receiveData(request, log), err => err.is.BadDigest); + }); + + it('should decode an unsigned payload with trailing checksum', async () => { + const body = chunkedBody(payloadCrc64); + const request = makeRequest( + body, + { + 'content-length': `${body.length}`, + 'x-amz-content-sha256': 'STREAMING-UNSIGNED-PAYLOAD-TRAILER', + 'x-amz-trailer': 'x-amz-checksum-crc64nvme', + 'x-amz-decoded-content-length': `${payload.length}`, + }, + payload.length, + ); + const data = await receiveData(request, log); + assert.strictEqual(data, payload); + }); + + it('should return BadDigest on trailing checksum mismatch', async () => { + const body = chunkedBody('AAAAAAAAAAA='); + const request = makeRequest( + body, + { + 'content-length': `${body.length}`, + 'x-amz-content-sha256': 'STREAMING-UNSIGNED-PAYLOAD-TRAILER', + 'x-amz-trailer': 'x-amz-checksum-crc64nvme', + 'x-amz-decoded-content-length': `${payload.length}`, + }, + payload.length, + ); + await assert.rejects(receiveData(request, log), err => err.is.BadDigest); + }); + + it('should reject an unsupported trailing checksum algorithm', async () => { + const request = makeRequest( + payload, + { + 'x-amz-content-sha256': 'STREAMING-UNSIGNED-PAYLOAD-TRAILER', + 'x-amz-trailer': 'x-amz-checksum-foo', + 'x-amz-decoded-content-length': `${payload.length}`, + }, + payload.length, + ); + await assert.rejects(receiveData(request, log), err => err.is.InvalidRequest); + }); + + it('should reject a body exceeding the announced content-length', async () => { + const request = makeRequest(payload, { 'x-amz-content-sha256': 'UNSIGNED-PAYLOAD' }, 10); + await assert.rejects(receiveData(request, log), err => err.is.InvalidRequest); + }); + + it('should reject a content-length over the allowed threshold', async () => { + const request = makeRequest(payload, { 'x-amz-content-sha256': 'UNSIGNED-PAYLOAD' }, 2 * 1024 * 1024); + await assert.rejects(receiveData(request, log), err => err.is.InvalidInput); + }); + + it('should reject a signed streaming request without v4 params', async () => { + const request = makeRequest( + payload, + { + 'x-amz-content-sha256': 'STREAMING-AWS4-HMAC-SHA256-PAYLOAD', + 'x-amz-decoded-content-length': `${payload.length}`, + }, + payload.length, + null, + ); + await assert.rejects(receiveData(request, log), err => err.is.InvalidArgument); + }); +}); From f5538f6ed488da5237417c7d99d0827987125f39 Mon Sep 17 00:00:00 2001 From: Thomas Flament Date: Tue, 28 Jul 2026 15:39:50 +0200 Subject: [PATCH 3/3] Migrate receiveData stream handling to async/await Replace the promise-executor and .then() bridge with await on the pipeline. Transform errors delivered through the errCb side-channel are bridged into a promise raced against the pipeline completion; promise settlement semantics replace the jsutil.once guard. Issue: CLDSRV-962 --- lib/routes/veeam/utils.js | 90 ++++++++++++++++++--------------------- 1 file changed, 42 insertions(+), 48 deletions(-) diff --git a/lib/routes/veeam/utils.js b/lib/routes/veeam/utils.js index 567cd347b9..9f19e0bab3 100644 --- a/lib/routes/veeam/utils.js +++ b/lib/routes/veeam/utils.js @@ -1,5 +1,5 @@ const xml2js = require('xml2js'); -const { errors, errorInstances, jsutil } = require('arsenal'); +const { errors, errorInstances } = require('arsenal'); const { Readable, Writable, pipeline: streamPipeline } = require('stream'); const { promisify } = require('util'); const collectResponseHeaders = require('../../utilities/collectResponseHeaders'); @@ -54,55 +54,49 @@ async function receiveData(request, log) { throw arsenalErrorFromChecksumError(headerChecksum); } const checksums = { primary: headerChecksum || defaultChecksumData, secondary: null }; - return await new Promise((resolve, reject) => { - const settle = jsutil.once((err, result) => { - if (err) { - return reject(err); + 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( + errorInstances.InvalidRequest.customizeDescription( + 'request body exceeds the announced content-length', + ), + ); } - 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( - errorInstances.InvalidRequest.customizeDescription( - 'request body exceeds the announced content-length', - ), - ); - } - chunks.push(chunk); - return cb(); - }, - final(cb) { - cb(); - }, - }); - const prepared = prepareStream(request, request.streamingV4Params, checksums, log, settle); - if (prepared.error) { - settle(prepared.error); - return; - } - pipeline(prepared.stream, collector).then( - () => { - // Checksum transforms only compute digests while streaming: - // validation against the expected values (header or trailer) - // must be done once the stream is fully consumed. - const checksumErr = - (prepared.contentSHA256Stream && prepared.contentSHA256Stream.validateChecksum()) || - prepared.stream.validateChecksum(); - if (checksumErr) { - log.debug('failed checksum validation', { error: checksumErr }); - return settle(arsenalErrorFromChecksumError(checksumErr)); - } - return settle(null, Buffer.concat(chunks).toString()); - }, - err => settle(err), - ); + chunks.push(chunk); + return cb(); + }, + }); + // Transform errors can be delivered through the errCb side-channel + // without necessarily erroring the pipeline, so bridge them into a + // promise raced against the pipeline completion. A repeated rejection + // after settlement is a no-op; the no-op handler below keeps the + // rejection handled even on paths that throw before the race subscribes. + let onStreamError; + const streamError = new Promise((resolve, reject) => { + onStreamError = reject; }); + streamError.catch(() => {}); + const prepared = prepareStream(request, request.streamingV4Params, checksums, log, onStreamError); + if (prepared.error) { + throw prepared.error; + } + await Promise.race([pipeline(prepared.stream, collector), streamError]); + // Checksum transforms only compute digests while streaming: validation + // against the expected values (header or trailer) must be done once the + // stream is fully consumed. + const checksumErr = + (prepared.contentSHA256Stream && prepared.contentSHA256Stream.validateChecksum()) || + prepared.stream.validateChecksum(); + if (checksumErr) { + log.debug('failed checksum validation', { error: checksumErr }); + throw arsenalErrorFromChecksumError(checksumErr); + } + return Buffer.concat(chunks).toString(); } /**