diff --git a/lib/routes/routeBackbeat.js b/lib/routes/routeBackbeat.js index c2225b6a76..e8876a373b 100644 --- a/lib/routes/routeBackbeat.js +++ b/lib/routes/routeBackbeat.js @@ -1289,7 +1289,12 @@ function routeBackbeat(clientIP, request, response, log) { [request.query.operation](request, response, log, next); } const versioningConfig = bucketInfo.getVersioningConfiguration(); - if (!versioningConfig || versioningConfig.Status !== 'Enabled') { + // The following makes sure that only replication destination-related operations + // target buckets with versioning enabled. + // This allows lifecycle expiration operations (getMetadata) to work on non-versioned buckets. + const isCRRDestinationRequest = request.method === 'PUT' && + (request.resourceType === 'data' || request.resourceType === 'metadata'); + if (isCRRDestinationRequest && (!versioningConfig || versioningConfig.Status !== 'Enabled')) { log.debug('bucket versioning is not enabled', { method: request.method, bucketName: request.bucketName, diff --git a/tests/unit/routes/routeBackbeat.js b/tests/unit/routes/routeBackbeat.js new file mode 100644 index 0000000000..cbcbd46006 --- /dev/null +++ b/tests/unit/routes/routeBackbeat.js @@ -0,0 +1,178 @@ +const assert = require('assert'); +const sinon = require('sinon'); +const metadataUtils = require('../../../lib/metadata/metadataUtils'); +const storeObject = require('../../../lib/api/apiUtils/object/storeObject'); +const metadata = require('../../../lib/metadata/wrapper'); +const { DummyRequestLogger } = require('../helpers'); +const DummyRequest = require('../DummyRequest'); + +const log = new DummyRequestLogger(); + +function prepareDummyRequest(headers = {}) { + const request = new DummyRequest({ + hostname: 'localhost', + method: 'PUT', + url: '/_/backbeat/metadata/bucket0/key0', + port: 80, + headers, + socket: { + remoteAddress: '0.0.0.0', + }, + }, '{"replicationInfo":"{}"}'); + return request; +} + +describe('routeBackbeat', () => { + let mockResponse; + let mockRequest; + let sandbox; + let endPromise; + let resolveEnd; + let routeBackbeat; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + + // create a Promise that resolves when response.end is called + endPromise = new Promise((resolve) => { resolveEnd = resolve; }); + + mockResponse = { + statusCode: null, + body: null, + setHeader: () => {}, + writeHead: sandbox.spy(statusCode => { + mockResponse.statusCode = statusCode; + }), + end: sandbox.spy((body, encoding, callback) => { + mockResponse.body = JSON.parse(body); + if (callback) callback(); + resolveEnd(); // Resolve the Promise when end is called + }), + }; + + mockRequest = prepareDummyRequest(); + + sandbox.stub(metadataUtils, 'standardMetadataValidateBucketAndObj'); + sandbox.stub(storeObject, 'dataStore'); + + // Clear require cache for routeBackbeat to make sure fresh module with stubbed dependencies + delete require.cache[require.resolve('../../../lib/routes/routeBackbeat')]; + routeBackbeat = require('../../../lib/routes/routeBackbeat'); + }); + + afterEach(() => { + sandbox.restore(); + }); + + const rejectionTests = [ + { + description: 'should reject CRR destination (putData) requests when versioning is disabled', + method: 'PUT', + url: '/_/backbeat/data/bucket0/key0', + }, + { + description: 'should reject CRR destination (putMetadata) requests when versioning is disabled', + method: 'PUT', + url: '/_/backbeat/metadata/bucket0/key0', + }, + ]; + + rejectionTests.forEach(({ description, method, url }) => { + it(description, async () => { + mockRequest.method = method; + mockRequest.url = url; + metadataUtils.standardMetadataValidateBucketAndObj.callsFake((params, denies, log, callback) => { + const bucketInfo = { + getVersioningConfiguration: () => ({ Status: 'Disabled' }), + }; + const objMd = {}; + callback(null, bucketInfo, objMd); + }); + + routeBackbeat('127.0.0.1', mockRequest, mockResponse, log); + + void await endPromise; + + assert.strictEqual(mockResponse.statusCode, 409); + assert.strictEqual(mockResponse.body.code, 'InvalidBucketState'); + }); + }); + + it('should allow non-CRR destination (getMetadata) requests regardless of versioning', async () => { + mockRequest.method = 'GET'; + + metadataUtils.standardMetadataValidateBucketAndObj.callsFake((params, denies, log, callback) => { + const bucketInfo = { + getVersioningConfiguration: () => ({ Status: 'Disabled' }), + }; + const objMd = {}; + callback(null, bucketInfo, objMd); + }); + + routeBackbeat('127.0.0.1', mockRequest, mockResponse, log); + + void await endPromise; + + assert.strictEqual(mockResponse.statusCode, 200); + assert.deepStrictEqual(mockResponse.body, { Body: '{}' }); + }); + + it('should allow CRR destination requests (putMetadata) when versioning is enabled', async () => { + mockRequest.method = 'PUT'; + mockRequest.url = '/_/backbeat/metadata/bucket0/key0'; + mockRequest.destroy = () => {}; + + sandbox.stub(metadata, 'putObjectMD').callsFake((bucketName, objectKey, omVal, options, logParam, cb) => { + cb(null, {}); + }); + + metadataUtils.standardMetadataValidateBucketAndObj.callsFake((params, denies, log, callback) => { + const bucketInfo = { + getVersioningConfiguration: () => ({ Status: 'Enabled' }), + isVersioningEnabled: () => true, + }; + const objMd = {}; + callback(null, bucketInfo, objMd); + }); + + routeBackbeat('127.0.0.1', mockRequest, mockResponse, log); + + void await endPromise; + + assert.strictEqual(mockResponse.statusCode, 200); + assert.deepStrictEqual(mockResponse.body, {}); + }); + + it('should allow CRR destination requests (putData) when versioning is enabled', async () => { + const md5 = '1234'; + mockRequest.method = 'PUT'; + mockRequest.url = '/_/backbeat/data/bucket0/key0'; + mockRequest.headers = { + 'x-scal-canonical-id': 'id', + 'content-md5': md5, + 'content-length': '0', + }; + mockRequest.destroy = () => {}; + + metadataUtils.standardMetadataValidateBucketAndObj.callsFake((params, denies, log, callback) => { + const bucketInfo = { + getVersioningConfiguration: () => ({ Status: 'Enabled' }), + isVersioningEnabled: () => true, + getLocationConstraint: () => undefined, + }; + const objMd = {}; + callback(null, bucketInfo, objMd); + }); + storeObject.dataStore.callsFake((objectContext, cipherBundle, stream, size, + streamingV4Params, backendInfo, log, callback) => { + callback(null, {}, md5); + }); + + routeBackbeat('127.0.0.1', mockRequest, mockResponse, log); + + void await endPromise; + + assert.strictEqual(mockResponse.statusCode, 200); + assert.deepStrictEqual(mockResponse.body, [{}]); + }); +});