From e2d0ba5be92a8f64b92621452dd994cda4313e35 Mon Sep 17 00:00:00 2001 From: Leif Henriksen Date: Mon, 6 Oct 2025 19:23:22 +0200 Subject: [PATCH 1/3] CLDSRV-754: add GetBucketLogging and PutBucketLogging endpoints --- constants.js | 11 +- lib/api/api.js | 4 + lib/api/bucketGetLogging.js | 55 ++++++ lib/api/bucketPutLogging.js | 78 ++++++++ tests/unit/api/bucketGetLogging.js | 271 +++++++++++++++++++++++++ tests/unit/api/bucketPutLogging.js | 305 +++++++++++++++++++++++++++++ 6 files changed, 722 insertions(+), 2 deletions(-) create mode 100644 lib/api/bucketGetLogging.js create mode 100644 lib/api/bucketPutLogging.js create mode 100644 tests/unit/api/bucketGetLogging.js create mode 100644 tests/unit/api/bucketPutLogging.js diff --git a/constants.js b/constants.js index f0cbe2c2ee..b9cdd49287 100644 --- a/constants.js +++ b/constants.js @@ -117,7 +117,6 @@ const constants = { 'accelerate', 'analytics', 'inventory', - 'logging', 'metrics', 'policyStatus', 'publicAccessBlock', @@ -191,6 +190,8 @@ const constants = { 'bucketPutReplication', 'bucketPutVersioning', 'bucketPutWebsite', + 'bucketPutLogging', + 'bucketGetLogging', 'objectDeleteTagging', 'objectGetTagging', 'objectPutTagging', @@ -267,7 +268,13 @@ const constants = { ], // if requester is not bucket owner, bucket policy actions should be denied with // MethodNotAllowed error - onlyOwnerAllowed: ['bucketDeletePolicy', 'bucketGetPolicy', 'bucketPutPolicy'], + onlyOwnerAllowed: [ + 'bucketDeletePolicy', + 'bucketGetPolicy', + 'bucketPutPolicy', + 'bucketPutLogging', + 'bucketGetLogging', + ], }; module.exports = constants; diff --git a/lib/api/api.js b/lib/api/api.js index 133a887e96..6ab4fcc77f 100644 --- a/lib/api/api.js +++ b/lib/api/api.js @@ -38,6 +38,8 @@ const bucketPutObjectLock = require('./bucketPutObjectLock'); const bucketUpdateQuota = require('./bucketUpdateQuota'); const bucketGetReplication = require('./bucketGetReplication'); const bucketDeleteReplication = require('./bucketDeleteReplication'); +const bucketGetLogging = require('./bucketGetLogging'); +const bucketPutLogging = require('./bucketPutLogging'); const corsPreflight = require('./corsPreflight'); const completeMultipartUpload = require('./completeMultipartUpload'); const initiateMultipartUpload = require('./initiateMultipartUpload'); @@ -368,6 +370,8 @@ const api = { bucketPutNotification, bucketGetNotification, bucketPutEncryption, + bucketGetLogging, + bucketPutLogging, corsPreflight, completeMultipartUpload, initiateMultipartUpload, diff --git a/lib/api/bucketGetLogging.js b/lib/api/bucketGetLogging.js new file mode 100644 index 0000000000..cf6804642f --- /dev/null +++ b/lib/api/bucketGetLogging.js @@ -0,0 +1,55 @@ +const { standardMetadataValidateBucket } = require('../metadata/metadataUtils'); +const { checkExpectedBucketOwner } = require('./apiUtils/authorization/bucketOwner'); +const monitoring = require('../utilities/monitoringHandler'); +const { waterfall } = require('async'); + +const BucketLoggingStatusNotFoundBody = '\n' + + ''; + +function bucketGetLogging(authInfo, request, log, callback) { + log.debug('processing request', { method: 'bucketGetLogging' }); + + const bucketName = request.bucketName; + const metadataValParams = { + authInfo, + bucketName, + requestType: request.apiMethods || 'bucketGetLogging', + request, + }; + + return waterfall([ + next => standardMetadataValidateBucket(metadataValParams, request.actionImplicitDenies, log, (err, bucket) => { + if (err) { + return next(err); + } + + return next(null, bucket); + }), + (bucket, next) => checkExpectedBucketOwner(request.headers, bucket, log, err => { + if (err) { + return next(err); + } + + return next(null, bucket); + }), + (bucket, next) => { + const bucketLoggingStatus = bucket.getBucketLoggingStatus(); + if (!bucketLoggingStatus) { + return next(null, BucketLoggingStatusNotFoundBody); + } + + return next(null, bucketLoggingStatus.toXML()); + } + ], (err, body) => { + if (err) { + log.trace('error processing request', { error: err, method: 'bucketGetLogging' }); + monitoring.promMetrics('GET', bucketName, err.code, 'bucketGetLogging'); + return callback(err); + } + + monitoring.promMetrics('GET', bucketName, '200', 'bucketGetLogging'); + return callback(null, body); + }); +} + +module.exports = bucketGetLogging; diff --git a/lib/api/bucketPutLogging.js b/lib/api/bucketPutLogging.js new file mode 100644 index 0000000000..01e62afebf --- /dev/null +++ b/lib/api/bucketPutLogging.js @@ -0,0 +1,78 @@ +const { waterfall } = require('async'); +const { standardMetadataValidateBucket } = require('../metadata/metadataUtils'); +const { checkExpectedBucketOwner } = require('./apiUtils/authorization/bucketOwner'); +const BucketLoggingStatus = require('arsenal').models.BucketLoggingStatus; +const metadata = require('../metadata/wrapper'); +const monitoring = require('../utilities/monitoringHandler'); +const { errorInstances } = require('arsenal'); + +function bucketPutLogging(authInfo, request, log, callback) { + log.debug('processing request', { method: 'bucketPutLogging' }); + + const bucketName = request.bucketName; + const parsed = BucketLoggingStatus.fromXML(request.post); + if (parsed.error) { + log.trace('error processing request', { error: parsed.error, method: 'bucketPutLogging' }); + monitoring.promMetrics('PUT', bucketName, parsed.error.arsenalError, 'bucketPutLogging'); + return callback(parsed.error.arsenalError); + } + + const metadataValParams = { + authInfo, + bucketName, + requestType: request.apiMethods || 'bucketPutLogging', + request, + }; + + return waterfall([ + next => standardMetadataValidateBucket(metadataValParams, request.actionImplicitDenies, log, (err, bucket) => { + if (err) { + return next(err); + } + + return next(null, bucket); + }), + (bucket, next) => { + const loggingEnabled = parsed.res.getLoggingEnabled(); + if (!loggingEnabled) { + return next(null, bucket); + } + + return metadata.getBucket(loggingEnabled.TargetBucket, log, err => { + if (err) { + return next(errorInstances.InvalidTargetBucketForLogging.customizeDescription(err.description)); + } + + return next(null, bucket); + }); + }, + (bucket, next) => checkExpectedBucketOwner(request.headers, bucket, log, err => { + if (err) { + return next(err); + } + + return next(null, bucket); + }), + (bucket, next) => { + bucket.setBucketLoggingStatus(parsed.res); + return metadata.updateBucket(bucket.getName(), bucket, log, err => { + if (err) { + return next(err); + } + + return next(); + }); + }, + ], err => { + if (err) { + log.trace('error processing request', { error: err, method: 'bucketPutLogging' }); + monitoring.promMetrics('PUT', bucketName, err.code, 'bucketPutLogging'); + return callback(err); + } + + monitoring.promMetrics('PUT', bucketName, '200', 'bucketPutLogging'); + return callback(); + }); +} + +module.exports = bucketPutLogging; diff --git a/tests/unit/api/bucketGetLogging.js b/tests/unit/api/bucketGetLogging.js new file mode 100644 index 0000000000..34d3bcfcc1 --- /dev/null +++ b/tests/unit/api/bucketGetLogging.js @@ -0,0 +1,271 @@ +const assert = require('assert'); +const { bucketPut } = require('../../../lib/api/bucketPut'); +const bucketPutLogging = require('../../../lib/api/bucketPutLogging'); +const bucketGetLogging = require('../../../lib/api/bucketGetLogging'); +const { cleanup, DummyRequestLogger, makeAuthInfo } = require('../helpers'); +const { parseString } = require('xml2js'); + +const log = new DummyRequestLogger(); +const authInfo = makeAuthInfo('accessKey1'); +const otherAuthInfo = makeAuthInfo('accessKey2'); +const bucketName = 'bucketgetloggingtest'; +const targetBucket = 'loggingbucket'; +const namespace = 'default'; + +const testBucketPutRequest = { + bucketName, + namespace, + headers: { host: `${bucketName}.s3.amazonaws.com` }, + url: '/', + actionImplicitDenies: false, +}; + +const testTargetBucketPutRequest = { + bucketName: targetBucket, + namespace, + headers: { host: `${targetBucket}.s3.amazonaws.com` }, + url: '/', + actionImplicitDenies: false, +}; + +function createLoggingRequest(bucketName, post, headers = {}) { + return { + bucketName, + namespace, + headers: { + host: `${bucketName}.s3.amazonaws.com`, + ...headers, + }, + url: '/?logging', + query: { logging: '' }, + post, + actionImplicitDenies: false, + }; +} + +function createGetLoggingRequest(bucketName, headers = {}) { + return { + bucketName, + namespace, + headers: { + host: `${bucketName}.s3.amazonaws.com`, + ...headers, + }, + url: '/?logging', + query: { logging: '' }, + actionImplicitDenies: false, + }; +} + +function createValidLoggingXML(targetBucket, targetPrefix = 'logs/') { + return '' + + '' + + '' + + `${targetBucket}` + + `${targetPrefix}` + + '' + + ''; +} + +describe('bucketGetLogging API', () => { + beforeEach(done => { + cleanup(); + return bucketPut(authInfo, testBucketPutRequest, log, err => { + if (err) { + return done(err); + } + return bucketPut(authInfo, testTargetBucketPutRequest, log, done); + }); + }); + + afterEach(() => cleanup()); + + it('should return empty BucketLoggingStatus when logging is not configured', done => { + const request = createGetLoggingRequest(bucketName); + + bucketGetLogging(authInfo, request, log, (err, xml) => { + assert.ifError(err); + assert(xml); + // Should return empty BucketLoggingStatus + const expectedXML = '\n' + + ''; + assert.strictEqual(xml, expectedXML); + done(); + }); + }); + + it('should return logging configuration when logging is enabled', done => { + const loggingXML = createValidLoggingXML(targetBucket, 'logs/'); + const putRequest = createLoggingRequest(bucketName, loggingXML); + + // First, set logging configuration + bucketPutLogging(authInfo, putRequest, log, err => { + assert.ifError(err); + + // Now get the logging configuration + const getRequest = createGetLoggingRequest(bucketName); + bucketGetLogging(authInfo, getRequest, log, (err, xml) => { + assert.ifError(err); + assert(xml); + // Verify the XML contains the expected values + assert(xml.includes('')); + assert(xml.includes(`${targetBucket}`)); + assert(xml.includes('logs/')); + done(); + }); + }); + }); + + it('should return logging configuration with custom TargetPrefix', done => { + const customPrefix = 'my-app-logs/2025/'; + const loggingXML = createValidLoggingXML(targetBucket, customPrefix); + const putRequest = createLoggingRequest(bucketName, loggingXML); + + bucketPutLogging(authInfo, putRequest, log, err => { + assert.ifError(err); + + const getRequest = createGetLoggingRequest(bucketName); + bucketGetLogging(authInfo, getRequest, log, (err, xml) => { + assert.ifError(err); + assert(xml); + assert(xml.includes(`${customPrefix}`)); + done(); + }); + }); + }); + + it('should return logging configuration with empty TargetPrefix', done => { + const loggingXML = createValidLoggingXML(targetBucket, ''); + const putRequest = createLoggingRequest(bucketName, loggingXML); + + bucketPutLogging(authInfo, putRequest, log, err => { + assert.ifError(err); + + const getRequest = createGetLoggingRequest(bucketName); + bucketGetLogging(authInfo, getRequest, log, (err, xml) => { + assert.ifError(err); + assert(xml); + assert(xml.includes('')); + done(); + }); + }); + }); + + it('should return empty status after logging is disabled', done => { + const loggingXML = createValidLoggingXML(targetBucket); + const putRequest = createLoggingRequest(bucketName, loggingXML); + + // First enable logging + bucketPutLogging(authInfo, putRequest, log, err => { + assert.ifError(err); + + // Disable logging + const disableXML = '' + + ''; + const disableRequest = createLoggingRequest(bucketName, disableXML); + + bucketPutLogging(authInfo, disableRequest, log, err => { + assert.ifError(err); + + // Get logging status - should be empty + const getRequest = createGetLoggingRequest(bucketName); + bucketGetLogging(authInfo, getRequest, log, (err, xml) => { + assert.ifError(err); + assert(xml); + + parseString(xml, { explicitArray: false }, (err, result) => { + assert.ifError(err); + // result should have BucketLoggingStatus, but no LoggingEnabled + assert(result); + assert(result.BucketLoggingStatus); + assert.strictEqual(result.BucketLoggingStatus.LoggingEnabled, undefined); + done(); + }); + }); + }); + }); + }); + + it('should validate expected bucket owner header - matching account', done => { + const loggingXML = createValidLoggingXML(targetBucket); + const putRequest = createLoggingRequest(bucketName, loggingXML); + + bucketPutLogging(authInfo, putRequest, log, err => { + assert.ifError(err); + + // The in-memory backend has account ID '123456789012' hardcoded + const accountId = '123456789012'; + const getRequest = createGetLoggingRequest(bucketName, { + 'x-amz-expected-bucket-owner': accountId, + }); + + bucketGetLogging(authInfo, getRequest, log, (err, xml) => { + assert.ifError(err); + assert(xml); + done(); + }); + }); + }); + + it('should return error for mismatched expected bucket owner', done => { + const wrongAccountId = '999999999999'; + const getRequest = createGetLoggingRequest(bucketName, { + 'x-amz-expected-bucket-owner': wrongAccountId, + }); + + bucketGetLogging(authInfo, getRequest, log, err => { + assert(err); + assert.strictEqual(err.is.AccessDenied, true); + done(); + }); + }); + + it('should return error for unauthorized access', done => { + const loggingXML = createValidLoggingXML(targetBucket); + const putRequest = createLoggingRequest(bucketName, loggingXML); + + // Set logging with owner + bucketPutLogging(authInfo, putRequest, log, err => { + assert.ifError(err); + + // Try to get logging with different auth + const getRequest = createGetLoggingRequest(bucketName); + bucketGetLogging(otherAuthInfo, getRequest, log, err => { + assert(err); + assert.strictEqual(err.is.MethodNotAllowed, true); + done(); + }); + }); + }); + + it('should return error for non-existent bucket', done => { + const getRequest = createGetLoggingRequest('nonexistentbucket'); + + bucketGetLogging(authInfo, getRequest, log, err => { + assert(err); + assert.strictEqual(err.is.NoSuchBucket, true); + done(); + }); + }); + + it('should return well-formed XML response', done => { + const loggingXML = createValidLoggingXML(targetBucket); + const putRequest = createLoggingRequest(bucketName, loggingXML); + + bucketPutLogging(authInfo, putRequest, log, err => { + assert.ifError(err); + + const getRequest = createGetLoggingRequest(bucketName); + bucketGetLogging(authInfo, getRequest, log, (err, xml) => { + assert.ifError(err); + assert(xml); + // Try to parse the XML, if no error, it's well-formed + parseString(xml, (parseErr, result) => { + assert.strictEqual(parseErr, null, `XML is not well-formed: ${parseErr}`); + assert(result); + done(); + }); + }); + }); + }); +}); diff --git a/tests/unit/api/bucketPutLogging.js b/tests/unit/api/bucketPutLogging.js new file mode 100644 index 0000000000..0f94147620 --- /dev/null +++ b/tests/unit/api/bucketPutLogging.js @@ -0,0 +1,305 @@ +const assert = require('assert'); +const { bucketPut } = require('../../../lib/api/bucketPut'); +const bucketPutLogging = require('../../../lib/api/bucketPutLogging'); +const { cleanup, DummyRequestLogger, makeAuthInfo } = require('../helpers'); +const metadata = require('../metadataswitch'); + +const log = new DummyRequestLogger(); +const authInfo = makeAuthInfo('accessKey1'); +const otherAuthInfo = makeAuthInfo('accessKey2'); +const bucketName = 'bucketputloggingtest'; +const targetBucket = 'loggingbucket'; +const namespace = 'default'; + +const testBucketPutRequest = { + bucketName, + namespace, + headers: { host: `${bucketName}.s3.amazonaws.com` }, + url: '/', + actionImplicitDenies: false, +}; + +const testTargetBucketPutRequest = { + bucketName: targetBucket, + namespace, + headers: { host: `${targetBucket}.s3.amazonaws.com` }, + url: '/', + actionImplicitDenies: false, +}; + +function createLoggingRequest(bucketName, post, headers = {}) { + return { + bucketName, + namespace, + headers: { + host: `${bucketName}.s3.amazonaws.com`, + ...headers, + }, + url: '/?logging', + query: { logging: '' }, + post, + actionImplicitDenies: false, + }; +} + +function createValidLoggingXML(targetBucket, targetPrefix = 'logs/') { + return '' + + '' + + '' + + `${targetBucket}` + + `${targetPrefix}` + + '' + + ''; +} + +function createEmptyLoggingXML() { + return '' + + ''; +} + +describe('bucketPutLogging API', () => { + beforeEach(done => { + cleanup(); + return bucketPut(authInfo, testBucketPutRequest, log, err => { + if (err) { + return done(err); + } + return bucketPut(authInfo, testTargetBucketPutRequest, log, done); + }); + }); + + afterEach(() => cleanup()); + + it('should set logging configuration on bucket', done => { + const loggingXML = createValidLoggingXML(targetBucket); + const request = createLoggingRequest(bucketName, loggingXML); + + bucketPutLogging(authInfo, request, log, err => { + assert.ifError(err); + metadata.getBucket(bucketName, log, (err, bucket) => { + assert.ifError(err); + const loggingConfig = bucket.getBucketLoggingStatus(); + assert(loggingConfig); + assert.strictEqual(loggingConfig.getLoggingEnabled().TargetBucket, targetBucket); + assert.strictEqual(loggingConfig.getLoggingEnabled().TargetPrefix, 'logs/'); + done(); + }); + }); + }); + + it('should disable logging with empty BucketLoggingStatus', done => { + // First enable logging + const enableLoggingXML = createValidLoggingXML(targetBucket); + const enableRequest = createLoggingRequest(bucketName, enableLoggingXML); + + bucketPutLogging(authInfo, enableRequest, log, err => { + assert.ifError(err); + + // Now disable logging + const disableLoggingXML = createEmptyLoggingXML(); + const disableRequest = createLoggingRequest(bucketName, disableLoggingXML); + + bucketPutLogging(authInfo, disableRequest, log, err => { + assert.ifError(err); + metadata.getBucket(bucketName, log, (err, bucket) => { + assert.ifError(err); + const loggingConfig = bucket.getBucketLoggingStatus(); + assert(loggingConfig); + // Empty config should have no LoggingEnabled + assert.strictEqual(loggingConfig.getLoggingEnabled(), undefined); + done(); + }); + }); + }); + }); + + it('should validate expected bucket owner header - matching account', done => { + const loggingXML = createValidLoggingXML(targetBucket); + // The in-memory backend has account ID '123456789012' hardcoded + const accountId = '123456789012'; + const request = createLoggingRequest(bucketName, loggingXML, { + 'x-amz-expected-bucket-owner': accountId, + }); + + bucketPutLogging(authInfo, request, log, err => { + assert.ifError(err); + done(); + }); + }); + + it('should return error for mismatched expected bucket owner', done => { + const loggingXML = createValidLoggingXML(targetBucket); + const wrongAccountId = '999999999999'; + const request = createLoggingRequest(bucketName, loggingXML, { + 'x-amz-expected-bucket-owner': wrongAccountId, + }); + + bucketPutLogging(authInfo, request, log, err => { + assert(err); + assert.strictEqual(err.is.AccessDenied, true); + done(); + }); + }); + + it('should handle empty request body', done => { + const request = createLoggingRequest(bucketName, ''); + + bucketPutLogging(authInfo, request, log, err => { + assert(err); + assert.strictEqual(err.is.MalformedXML, true); + done(); + }); + }); + + it('should handle missing request body', done => { + const request = createLoggingRequest(bucketName, undefined); + + bucketPutLogging(authInfo, request, log, err => { + assert(err); + // Missing body should trigger XML parsing error + assert(err.is.MalformedXML); + done(); + }); + }); + + it('should return error for unauthorized access', done => { + const loggingXML = createValidLoggingXML(targetBucket); + const request = createLoggingRequest(bucketName, loggingXML); + + // Try to set logging with different auth + bucketPutLogging(otherAuthInfo, request, log, err => { + assert(err); + assert.strictEqual(err.is.MethodNotAllowed, true); + done(); + }); + }); + + it('should return error for non-existent bucket', done => { + const loggingXML = createValidLoggingXML(targetBucket); + const request = createLoggingRequest('nonexistentbucket', loggingXML); + + bucketPutLogging(authInfo, request, log, err => { + assert(err); + assert.strictEqual(err.is.NoSuchBucket, true); + done(); + }); + }); + + it('should return error for malformed XML - missing closing tag', done => { + const malformedXML = '' + + '' + + '' + + `${targetBucket}` + + 'logs/' + + // Missing and + ''; + const request = createLoggingRequest(bucketName, malformedXML); + + bucketPutLogging(authInfo, request, log, err => { + assert(err); + assert.strictEqual(err.is.MalformedXML, true); + done(); + }); + }); + + it('should return error for malformed XML - invalid structure', done => { + const malformedXML = '' + + '' + + '' + + 'invalid' + // Invalid tag + '' + + ''; + const request = createLoggingRequest(bucketName, malformedXML); + + bucketPutLogging(authInfo, request, log, err => { + assert(err); + // Should fail validation + assert(err.is.MalformedXML); + done(); + }); + }); + + it('should return error for malformed XML - not XML at all', done => { + const malformedXML = 'This is not XML at all'; + const request = createLoggingRequest(bucketName, malformedXML); + + bucketPutLogging(authInfo, request, log, err => { + assert(err); + assert.strictEqual(err.is.MalformedXML, true); + done(); + }); + }); + + it('should return NotImplemented error when TargetGrants is present', done => { + const loggingXMLWithGrants = '' + + '' + + '' + + `${targetBucket}` + + 'logs/' + + '' + + '' + + '' + + '79a59df900b949e55d96a1e698fbacedfd6e09d98eacf8f8d5218e7cd47ef2be' + + 'GranteeDisplayName' + + '' + + 'READ' + + '' + + '' + + '' + + ''; + const request = createLoggingRequest(bucketName, loggingXMLWithGrants); + + bucketPutLogging(authInfo, request, log, err => { + assert(err); + assert.strictEqual(err.is.NotImplemented, true); + done(); + }); + }); + + it('should handle logging with custom TargetPrefix', done => { + const customPrefix = 'my-app-logs/2025/'; + const loggingXML = createValidLoggingXML(targetBucket, customPrefix); + const request = createLoggingRequest(bucketName, loggingXML); + + bucketPutLogging(authInfo, request, log, err => { + assert.ifError(err); + metadata.getBucket(bucketName, log, (err, bucket) => { + assert.ifError(err); + const loggingConfig = bucket.getBucketLoggingStatus(); + assert(loggingConfig); + assert(loggingConfig.getLoggingEnabled()); + assert.strictEqual(loggingConfig.getLoggingEnabled().TargetPrefix, customPrefix); + done(); + }); + }); + }); + + it('should handle logging with empty TargetPrefix', done => { + const loggingXML = createValidLoggingXML(targetBucket, ''); + const request = createLoggingRequest(bucketName, loggingXML); + + bucketPutLogging(authInfo, request, log, err => { + assert.ifError(err); + metadata.getBucket(bucketName, log, (err, bucket) => { + assert.ifError(err); + const loggingConfig = bucket.getBucketLoggingStatus(); + assert(loggingConfig); + assert(loggingConfig.getLoggingEnabled()); + assert.strictEqual(loggingConfig.getLoggingEnabled().TargetPrefix, ''); + done(); + }); + }); + }); + + it('should reject TargetPrefix if target bucket does not exist', done => { + const loggingXML = createValidLoggingXML('non-existing-bucket', ''); + const request = createLoggingRequest(bucketName, loggingXML); + + bucketPutLogging(authInfo, request, log, err => { + assert(err); + assert.strictEqual(err.is.InvalidTargetBucketForLogging, true); + done(); + }); + }); +}); From 57bf77255680cec970955f25260b2aa914c6d604 Mon Sep 17 00:00:00 2001 From: Leif Henriksen Date: Wed, 8 Oct 2025 18:24:47 +0200 Subject: [PATCH 2/3] CLDSRV-754: add s3-node-sdk functional tests --- .../test/bucket/getBucketLogging.js | 100 +++++++++++ .../test/bucket/putBucketLogging.js | 157 ++++++++++++++++++ 2 files changed, 257 insertions(+) create mode 100644 tests/functional/aws-node-sdk/test/bucket/getBucketLogging.js create mode 100644 tests/functional/aws-node-sdk/test/bucket/putBucketLogging.js diff --git a/tests/functional/aws-node-sdk/test/bucket/getBucketLogging.js b/tests/functional/aws-node-sdk/test/bucket/getBucketLogging.js new file mode 100644 index 0000000000..587973ed45 --- /dev/null +++ b/tests/functional/aws-node-sdk/test/bucket/getBucketLogging.js @@ -0,0 +1,100 @@ +const assert = require('assert'); + +const withV4 = require('../support/withV4'); +const BucketUtility = require('../../lib/utility/bucket-util'); + +const bucketName = 'testgetloggingbucket'; +const targetBucket = 'testloggingtargetbucket'; + +const validLoggingConfig = { + LoggingEnabled: { + TargetBucket: targetBucket, + TargetPrefix: 'logs/', + }, +}; + +describe('GET bucket logging', () => { + withV4(sigCfg => { + const bucketUtil = new BucketUtility('default', sigCfg); + const s3 = bucketUtil.s3; + + afterEach(done => { + process.stdout.write('Deleting buckets\n'); + bucketUtil.deleteOne(bucketName).then(() => bucketUtil.deleteOne(targetBucket)).then(() => done()) + .catch(err => { + if (err && err.code !== 'NoSuchBucket') { + return done(err); + } + return done(); + }); + }); + + describe('without existing bucket', () => { + it('should return NoSuchBucket', done => { + s3.getBucketLogging({ Bucket: bucketName }, err => { + assert(err); + assert.strictEqual(err.code, 'NoSuchBucket'); + assert.strictEqual(err.statusCode, 404); + return done(); + }); + }); + }); + + describe('on bucket without logging configuration', () => { + before(done => { + process.stdout.write('Creating bucket without logging\n'); + s3.createBucket({ Bucket: bucketName }, err => { + if (err) { + process.stdout.write('error creating bucket', err); + return done(err); + } + return done(); + }); + }); + + it('should return empty BucketLoggingStatus', done => { + s3.getBucketLogging({ Bucket: bucketName }, (err, data) => { + assert.strictEqual(err, null, + `Found unexpected err ${err}`); + // When no logging is configured, AWS returns empty object + assert(data); + assert.strictEqual(Object.keys(data).length, 0, 'Expected data to have no keys'); + return done(); + }); + }); + }); + + describe('with existing logging configuration', () => { + before(done => { + process.stdout.write('Creating buckets and setting logging\n'); + return s3.createBucket({ Bucket: bucketName }, err => { + if (err) { + return done(err); + } + return s3.createBucket({ Bucket: targetBucket }, err => { + if (err) { + return done(err); + } + return s3.putBucketLogging({ + Bucket: bucketName, + BucketLoggingStatus: validLoggingConfig, + }, done); + }); + }); + }); + + it('should return bucket logging configuration successfully', done => { + s3.getBucketLogging({ Bucket: bucketName }, (err, data) => { + assert.strictEqual(err, null, + `Found unexpected err ${err}`); + assert(data.LoggingEnabled); + assert.strictEqual(data.LoggingEnabled.TargetBucket, + targetBucket); + assert.strictEqual(data.LoggingEnabled.TargetPrefix, 'logs/'); + return done(); + }); + }); + }); + }); +}); + diff --git a/tests/functional/aws-node-sdk/test/bucket/putBucketLogging.js b/tests/functional/aws-node-sdk/test/bucket/putBucketLogging.js new file mode 100644 index 0000000000..03c9a613ce --- /dev/null +++ b/tests/functional/aws-node-sdk/test/bucket/putBucketLogging.js @@ -0,0 +1,157 @@ +const assert = require('assert'); + +const withV4 = require('../support/withV4'); +const BucketUtility = require('../../lib/utility/bucket-util'); + +const bucketName = 'testputloggingbucket'; +const targetBucket = 'testloggingtargetbucket'; + +const validLoggingConfig = { + LoggingEnabled: { + TargetBucket: targetBucket, + TargetPrefix: 'logs/', + }, +}; + +const validLoggingConfigWithGrants = { + LoggingEnabled: { + TargetBucket: targetBucket, + TargetPrefix: 'access-logs/', + TargetGrants: [ + { + Grantee: { + Type: 'Group', + URI: 'http://acs.amazonaws.com/groups/s3/LogDelivery', + }, + Permission: 'WRITE', + }, + { + Grantee: { + Type: 'Group', + URI: 'http://acs.amazonaws.com/groups/s3/LogDelivery', + }, + Permission: 'READ_ACP', + }, + ], + }, +}; + +describe('PUT bucket logging', () => { + withV4(sigCfg => { + const bucketUtil = new BucketUtility('default', sigCfg); + const s3 = bucketUtil.s3; + const otherAccountBucketUtility = new BucketUtility('lisa', {}); + const otherAccountS3 = otherAccountBucketUtility.s3; + + function _testPutBucketLoggingError(account, config, statusCode, errMsg, cb) { + account.putBucketLogging({ + Bucket: bucketName, + BucketLoggingStatus: config, + }, err => { + assert(err, 'Expected err but found none'); + assert.strictEqual(err.code, errMsg); + assert.strictEqual(err.statusCode, statusCode); + cb(); + }); + } + + describe('without existing bucket', () => { + it('should return NoSuchBucket', done => { + _testPutBucketLoggingError(s3, validLoggingConfig, 404, 'NoSuchBucket', done); + }); + }); + + describe('with existing bucket', () => { + beforeEach(done => { + process.stdout.write('Creating buckets\n'); + return s3.createBucket({ Bucket: bucketName }, err => { + if (err) { + return done(err); + } + return s3.createBucket({ Bucket: targetBucket }, done); + }); + }); + + afterEach(done => { + process.stdout.write('Deleting buckets\n'); + bucketUtil.deleteOne(bucketName).then(() => bucketUtil.deleteOne(targetBucket)).then(() => done()) + .catch(err => { + if (err && err.code !== 'NoSuchBucket') { + return done(err); + } + return done(); + }); + }); + + it('should put bucket logging configuration successfully', done => { + s3.putBucketLogging({ + Bucket: bucketName, + BucketLoggingStatus: validLoggingConfig, + }, err => { + assert.ifError(err); + // Verify the config was set by getting it back + s3.getBucketLogging({ Bucket: bucketName }, (err, data) => { + assert.ifError(err); + assert(data.LoggingEnabled); + assert.strictEqual(data.LoggingEnabled.TargetBucket, + targetBucket); + assert.strictEqual(data.LoggingEnabled.TargetPrefix, + 'logs/'); + return done(); + }); + }); + }); + + it('should return NotImplemented if TargetGrants is present', done => { + _testPutBucketLoggingError(s3, validLoggingConfigWithGrants, 501, 'NotImplemented', done); + }); + + it('should disable logging with empty BucketLoggingStatus', done => { + // First enable logging + s3.putBucketLogging({ + Bucket: bucketName, + BucketLoggingStatus: validLoggingConfig, + }, err => { + assert.strictEqual(err, null); + // Verify it was enabled + s3.getBucketLogging({ Bucket: bucketName }, (err, data) => { + assert.strictEqual(err, null); + assert(data.LoggingEnabled); + // Now disable logging + s3.putBucketLogging({ + Bucket: bucketName, + BucketLoggingStatus: {}, + }, err => { + assert.strictEqual(err, null, + `Found unexpected err ${err}`); + // Verify it was disabled + s3.getBucketLogging({ Bucket: bucketName }, + (err, data) => { + assert.strictEqual(err, null); + assert(data); + assert.deepStrictEqual(data, {}); + return done(); + }); + }); + }); + }); + }); + + it('should return MethodNotAllowed if user is not bucket owner', done => { + _testPutBucketLoggingError(otherAccountS3, validLoggingConfig, 405, 'MethodNotAllowed', done); + }); + + it('should return InvalidTargetBucketForLogging if target bucket does not exist', + done => { + const invalidConfig = { + LoggingEnabled: { + TargetBucket: 'nonexistentbucket', + TargetPrefix: 'logs/', + }, + }; + return _testPutBucketLoggingError(s3, invalidConfig, 400, 'InvalidTargetBucketForLogging', done); + }); + }); + }); +}); + From d01330663181b00c5c2b8a6bfd0f651b0941b594 Mon Sep 17 00:00:00 2001 From: Leif Henriksen Date: Tue, 14 Oct 2025 18:00:30 +0200 Subject: [PATCH 3/3] CLDSRV-754: bump arsenal version to 8.2.33 --- package.json | 2 +- yarn.lock | 78 ++++++++++++++++++++++++++++++++++++---------------- 2 files changed, 55 insertions(+), 25 deletions(-) diff --git a/package.json b/package.json index 3d9c646539..9434311de9 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "dependencies": { "@azure/storage-blob": "^12.28.0", "@hapi/joi": "^17.1.1", - "arsenal": "git+https://github.com/scality/Arsenal#8.2.32", + "arsenal": "git+https://github.com/scality/Arsenal#8.2.33", "async": "2.6.4", "aws-sdk": "^2.1692.0", "bucketclient": "scality/bucketclient#8.2.5", diff --git a/yarn.lock b/yarn.lock index 44695c2f2b..0e88a9d91b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -202,7 +202,7 @@ fast-xml-parser "^5.0.7" tslib "^2.8.1" -"@azure/identity@^4.10.2", "@azure/identity@^4.12.0", "@azure/identity@^4.5.0": +"@azure/identity@^4.10.2", "@azure/identity@^4.5.0": version "4.12.0" resolved "https://registry.yarnpkg.com/@azure/identity/-/identity-4.12.0.tgz#272e39a8742191aac0b9f5ec683984bf4d88e7cb" integrity sha512-6vuh2R3Cte6SD6azNalLCjIDoryGdcvDVEV7IDRPtm5lHX5ffkDlIalaoOp5YJU08e4ipjJENel20kSMDLAcug== @@ -219,6 +219,23 @@ open "^10.1.0" tslib "^2.2.0" +"@azure/identity@^4.13.0": + version "4.13.0" + resolved "https://registry.yarnpkg.com/@azure/identity/-/identity-4.13.0.tgz#b2be63646964ab59e0dc0eadca8e4f562fc31f96" + integrity sha512-uWC0fssc+hs1TGGVkkghiaFkkS7NkTxfnCH+Hdg+yTehTpMcehpok4PgUKKdyCH+9ldu6FhiHRv84Ntqj1vVcw== + dependencies: + "@azure/abort-controller" "^2.0.0" + "@azure/core-auth" "^1.9.0" + "@azure/core-client" "^1.9.2" + "@azure/core-rest-pipeline" "^1.17.0" + "@azure/core-tracing" "^1.0.0" + "@azure/core-util" "^1.11.0" + "@azure/logger" "^1.0.0" + "@azure/msal-browser" "^4.2.0" + "@azure/msal-node" "^3.5.0" + open "^10.1.0" + tslib "^2.2.0" + "@azure/logger@^1.0.0": version "1.1.4" resolved "https://registry.yarnpkg.com/@azure/logger/-/logger-1.1.4.tgz#223cbf2b424dfa66478ce9a4f575f59c6f379768" @@ -611,16 +628,16 @@ resolved "https://registry.yarnpkg.com/@humanwhocodes/retry/-/retry-0.4.2.tgz#1860473de7dfa1546767448f333db80cb0ff2161" integrity sha512-xeO57FpIu4p1Ri3Jq/EXq4ClRm86dVF2z/+kvFnyqVYRavTZmaFaUBbWCOuuTh0o/g7DSsk6kc2vrS4Vl5oPOQ== +"@ioredis/commands@1.4.0", "@ioredis/commands@^1.3.0": + version "1.4.0" + resolved "https://registry.yarnpkg.com/@ioredis/commands/-/commands-1.4.0.tgz#9f657d51cdd5d2fdb8889592aa4a355546151f25" + integrity sha512-aFT2yemJJo+TZCmieA7qnYGQooOS7QfNmYrzGtsYd3g9j5iDP8AimYYAesf79ohjbLG12XxC4nG5DyEnC88AsQ== + "@ioredis/commands@^1.1.1": version "1.2.0" resolved "https://registry.yarnpkg.com/@ioredis/commands/-/commands-1.2.0.tgz#6d61b3097470af1fdbbe622795b8921d42018e11" integrity sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg== -"@ioredis/commands@^1.3.0": - version "1.4.0" - resolved "https://registry.yarnpkg.com/@ioredis/commands/-/commands-1.4.0.tgz#9f657d51cdd5d2fdb8889592aa4a355546151f25" - integrity sha512-aFT2yemJJo+TZCmieA7qnYGQooOS7QfNmYrzGtsYd3g9j5iDP8AimYYAesf79ohjbLG12XxC4nG5DyEnC88AsQ== - "@isaacs/cliui@^8.0.2": version "8.0.2" resolved "https://registry.yarnpkg.com/@isaacs/cliui/-/cliui-8.0.2.tgz#b37667b7bc181c168782259bab42474fbf52b550" @@ -1414,11 +1431,11 @@ arraybuffer.prototype.slice@^1.0.4: optionalDependencies: ioctl "^2.0.2" -"arsenal@git+https://github.com/scality/Arsenal#8.2.32": - version "8.2.32" - resolved "git+https://github.com/scality/Arsenal#f14ee39934837b9b0dd14ab442945e4e5d4947ff" +"arsenal@git+https://github.com/scality/Arsenal#8.2.33": + version "8.2.33" + resolved "git+https://github.com/scality/Arsenal#34b7f08652c8eb765551c3422dd6418c0c65a79b" dependencies: - "@azure/identity" "^4.12.0" + "@azure/identity" "^4.13.0" "@azure/storage-blob" "^12.28.0" "@js-sdsl/ordered-set" "^4.4.2" "@scality/hdclient" "^1.3.1" @@ -1430,16 +1447,16 @@ arraybuffer.prototype.slice@^1.0.4: backo "^1.1.0" base-x "3.0.8" base62 "^2.0.2" - debug "^4.4.1" + debug "^4.4.3" fcntl "github:scality/node-fcntl#0.3.0" httpagent scality/httpagent#1.1.0 https-proxy-agent "^7.0.6" - ioredis "^5.7.0" + ioredis "^5.8.1" ipaddr.js "^2.2.0" joi "^18.0.1" level "~5.0.1" level-sublevel "~6.6.5" - mongodb "^6.19.0" + mongodb "^6.20.0" node-forge "^1.3.1" prom-client "^15.1.3" simple-glob "^0.2.0" @@ -2196,6 +2213,13 @@ debug@^4, debug@^4.4.0, debug@^4.4.1: dependencies: ms "^2.1.3" +debug@^4.4.3: + version "4.4.3" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.3.tgz#c6ae432d9bd9662582fce08709b038c58e9e3d6a" + integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA== + dependencies: + ms "^2.1.3" + debug@~4.3.1, debug@~4.3.2, debug@~4.3.4: version "4.3.7" resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.7.tgz#87945b4151a011d76d95a198d7111c865c360a52" @@ -3676,7 +3700,7 @@ ioredis@^5.4.1: redis-parser "^3.0.0" standard-as-callback "^2.1.0" -ioredis@^5.4.2, ioredis@^5.7.0: +ioredis@^5.4.2: version "5.7.0" resolved "https://registry.yarnpkg.com/ioredis/-/ioredis-5.7.0.tgz#be8f4a09bfb67bfa84ead297ff625973a5dcefc3" integrity sha512-NUcA93i1lukyXU+riqEyPtSEkyFq8tX90uL659J+qpCZ3rEdViB/APC58oAhIh3+bJln2hzdlZbBZsGNrlsR8g== @@ -3706,6 +3730,21 @@ ioredis@^5.6.1: redis-parser "^3.0.0" standard-as-callback "^2.1.0" +ioredis@^5.8.1: + version "5.8.1" + resolved "https://registry.yarnpkg.com/ioredis/-/ioredis-5.8.1.tgz#2d2dae406be71665607906f57b3c971bb4b089ae" + integrity sha512-Qho8TgIamqEPdgiMadJwzRMW3TudIg6vpg4YONokGDudy4eqRIJtDbVX72pfLBcWxvbn3qm/40TyGUObdW4tLQ== + dependencies: + "@ioredis/commands" "1.4.0" + cluster-key-slot "^1.1.0" + debug "^4.3.4" + denque "^2.1.0" + lodash.defaults "^4.2.0" + lodash.isarguments "^3.1.0" + redis-errors "^1.2.0" + redis-parser "^3.0.0" + standard-as-callback "^2.1.0" + ip-address@^9.0.5: version "9.0.5" resolved "https://registry.yarnpkg.com/ip-address/-/ip-address-9.0.5.tgz#117a960819b08780c3bd1f14ef3c1cc1d3f3ea5a" @@ -5046,7 +5085,7 @@ mongodb@^6.11.0: bson "^6.10.3" mongodb-connection-string-url "^3.0.0" -mongodb@^6.17.0: +mongodb@^6.17.0, mongodb@^6.20.0: version "6.20.0" resolved "https://registry.yarnpkg.com/mongodb/-/mongodb-6.20.0.tgz#5212dcf512719385287aa4574265352eefb01d8e" integrity sha512-Tl6MEIU3K4Rq3TSHd+sZQqRBoGlFsOgNrH5ltAcFBV62Re3Fd+FcaVf8uSEQFOJ51SDowDVttBTONMfoYWrWlQ== @@ -5055,15 +5094,6 @@ mongodb@^6.17.0: bson "^6.10.4" mongodb-connection-string-url "^3.0.2" -mongodb@^6.19.0: - version "6.19.0" - resolved "https://registry.yarnpkg.com/mongodb/-/mongodb-6.19.0.tgz#d28df0ae4cb3bea4381206e2d9efc3c7b77531fe" - integrity sha512-H3GtYujOJdeKIMLKBT9PwlDhGrQfplABNF1G904w6r5ZXKWyv77aB0X9B+rhmaAwjtllHzaEkvi9mkGVZxs2Bw== - dependencies: - "@mongodb-js/saslprep" "^1.1.9" - bson "^6.10.4" - mongodb-connection-string-url "^3.0.0" - ms@2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8"