Skip to content

Commit 6d352b5

Browse files
Add unit tests for abortMPU
Issue: CLDSRV-669
1 parent 9d3829d commit 6d352b5

File tree

1 file changed

+366
-0
lines changed

1 file changed

+366
-0
lines changed
Lines changed: 366 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,366 @@
1+
const assert = require('assert');
2+
const sinon = require('sinon');
3+
const { parseString } = require('xml2js');
4+
const { errors } = require('arsenal');
5+
6+
const abortMultipartUpload = require('../../../../../lib/api/apiUtils/object/abortMultipartUpload');
7+
const { bucketPut } = require('../../../../../lib/api/bucketPut');
8+
const initiateMultipartUpload = require('../../../../../lib/api/initiateMultipartUpload');
9+
const bucketPutVersioning = require('../../../../../lib/api/bucketPutVersioning');
10+
const objectPutPart = require('../../../../../lib/api/objectPutPart');
11+
const { data } = require('../../../../../lib/data/wrapper');
12+
const quotaUtils = require('../../../../../lib/api/apiUtils/quotas/quotaUtils');
13+
const { DummyRequestLogger, makeAuthInfo, cleanup, versioningTestUtils } = require('../../../helpers');
14+
const DummyRequest = require('../../../DummyRequest');
15+
16+
describe('abortMultipartUpload', () => {
17+
const log = new DummyRequestLogger();
18+
const authInfo = makeAuthInfo('testCanonicalId');
19+
const bucketName = 'test-bucket';
20+
const objectKey = 'test-object';
21+
const postBody = Buffer.from('I am a part', 'utf8');
22+
23+
const bucketRequest = new DummyRequest({
24+
bucketName,
25+
namespace: 'default',
26+
headers: { host: `${bucketName}.s3.amazonaws.com` },
27+
url: '/',
28+
});
29+
30+
const initiateRequest = new DummyRequest({
31+
bucketName,
32+
namespace: 'default',
33+
objectKey,
34+
headers: { host: `${bucketName}.s3.amazonaws.com` },
35+
url: `/${objectKey}?uploads`,
36+
actionImplicitDenies: false,
37+
});
38+
39+
const abortRequest = new DummyRequest({
40+
bucketName,
41+
namespace: 'default',
42+
objectKey,
43+
headers: { host: `${bucketName}.s3.amazonaws.com` },
44+
url: `/${objectKey}?uploadId=test-upload-id`,
45+
query: { uploadId: 'test-upload-id' },
46+
apiMethods: 'multipartDelete',
47+
actionImplicitDenies: false,
48+
accountQuotas: {},
49+
});
50+
51+
const enableVersioningRequest = versioningTestUtils.createBucketPutVersioningReq(bucketName, 'Enabled');
52+
53+
let dataAbortMPUStub;
54+
let dataDeleteStub;
55+
let validateQuotasStub;
56+
57+
beforeEach(() => {
58+
cleanup();
59+
// Stub external operations that we don't want to test
60+
dataAbortMPUStub = sinon.stub(data, 'abortMPU').callsFake((objectKey, uploadId, location, bucketName, request, destBucket, locationConstraintCheckFn, log, callback) => {
61+
// Call the callback immediately without executing locationConstraintCheck
62+
callback(null, false);
63+
});
64+
dataDeleteStub = sinon.stub(data, 'delete').yields();
65+
validateQuotasStub = sinon.stub(quotaUtils, 'validateQuotas').yields();
66+
});
67+
68+
afterEach(() => {
69+
sinon.restore();
70+
});
71+
72+
// Helper to create bucket and MPU, returns uploadId
73+
function createBucketAndMPU(versioned, callback) {
74+
if (versioned) {
75+
bucketPut(authInfo, bucketRequest, log, (err) => {
76+
if (err) return callback(err);
77+
bucketPutVersioning(authInfo, enableVersioningRequest, log, (err) => {
78+
if (err) return callback(err);
79+
initiateMultipartUpload(authInfo, initiateRequest, log, (err, result) => {
80+
if (err) return callback(err);
81+
parseString(result, (err, json) => {
82+
if (err) return callback(err);
83+
const uploadId = json.InitiateMultipartUploadResult.UploadId[0];
84+
callback(null, uploadId);
85+
});
86+
});
87+
});
88+
});
89+
} else {
90+
bucketPut(authInfo, bucketRequest, log, (err) => {
91+
if (err) return callback(err);
92+
initiateMultipartUpload(authInfo, initiateRequest, log, (err, result) => {
93+
if (err) return callback(err);
94+
parseString(result, (err, json) => {
95+
if (err) return callback(err);
96+
const uploadId = json.InitiateMultipartUploadResult.UploadId[0];
97+
callback(null, uploadId);
98+
});
99+
});
100+
});
101+
}
102+
}
103+
104+
describe('basic functionality', () => {
105+
it('should successfully abort multipart upload', (done) => {
106+
createBucketAndMPU(false, (err, uploadId) => {
107+
assert.ifError(err);
108+
109+
abortMultipartUpload(authInfo, bucketName, objectKey, uploadId, log, (err) => {
110+
assert.strictEqual(err, null);
111+
sinon.assert.calledOnce(dataAbortMPUStub);
112+
done();
113+
}, abortRequest);
114+
});
115+
});
116+
117+
it('should return error for non-existent bucket', (done) => {
118+
abortMultipartUpload(authInfo, 'non-existent-bucket', objectKey, 'fake-upload-id', log, (err) => {
119+
assert(err);
120+
assert.strictEqual(err.is.NoSuchBucket, true);
121+
done();
122+
}, abortRequest);
123+
});
124+
125+
it('should return error for non-existent upload', (done) => {
126+
bucketPut(authInfo, bucketRequest, log, (err) => {
127+
assert.ifError(err);
128+
129+
abortMultipartUpload(authInfo, bucketName, objectKey, 'fake-upload-id', log, (err) => {
130+
assert(err);
131+
assert.strictEqual(err.is.NoSuchUpload, true);
132+
done();
133+
}, abortRequest);
134+
});
135+
});
136+
137+
it('should skip data deletion when skipDataDelete is true', (done) => {
138+
dataAbortMPUStub.restore();
139+
sinon.stub(data, 'abortMPU').callsFake((objectKey, uploadId, location, bucketName, request, destBucket, locationConstraintCheckFn, log, callback) => {
140+
callback(null, true); // skipDataDelete = true
141+
});
142+
143+
createBucketAndMPU(false, (err, uploadId) => {
144+
assert.ifError(err);
145+
146+
abortMultipartUpload(authInfo, bucketName, objectKey, uploadId, log, (err) => {
147+
assert.strictEqual(err, null);
148+
sinon.assert.notCalled(dataDeleteStub);
149+
sinon.assert.notCalled(validateQuotasStub);
150+
done();
151+
}, abortRequest);
152+
});
153+
});
154+
});
155+
156+
describe('with multipart upload parts', () => {
157+
it('should delete part data when aborting', (done) => {
158+
createBucketAndMPU(false, (err, uploadId) => {
159+
assert.ifError(err);
160+
161+
// Add a part to the multipart upload
162+
const md5Hash = require('crypto').createHash('md5');
163+
md5Hash.update(postBody);
164+
const calculatedHash = md5Hash.digest('hex');
165+
166+
const partRequest = new DummyRequest({
167+
bucketName,
168+
objectKey,
169+
namespace: 'default',
170+
url: `/${objectKey}?partNumber=1&uploadId=${uploadId}`,
171+
headers: { host: `${bucketName}.s3.amazonaws.com` },
172+
query: {
173+
partNumber: '1',
174+
uploadId,
175+
},
176+
calculatedHash,
177+
}, postBody);
178+
179+
objectPutPart(authInfo, partRequest, undefined, log, (err) => {
180+
assert.ifError(err);
181+
182+
abortMultipartUpload(authInfo, bucketName, objectKey, uploadId, log, (err) => {
183+
assert.strictEqual(err, null);
184+
sinon.assert.calledOnce(dataAbortMPUStub);
185+
sinon.assert.called(dataDeleteStub); // Should delete part data
186+
done();
187+
}, abortRequest);
188+
});
189+
});
190+
});
191+
});
192+
193+
describe('versioned bucket behavior', () => {
194+
it('should handle versioned bucket abort', (done) => {
195+
createBucketAndMPU(true, (err, uploadId) => {
196+
assert.ifError(err);
197+
198+
abortMultipartUpload(authInfo, bucketName, objectKey, uploadId, log, (err) => {
199+
assert.strictEqual(err, null);
200+
sinon.assert.calledOnce(dataAbortMPUStub);
201+
done();
202+
}, abortRequest);
203+
});
204+
});
205+
});
206+
207+
describe('version listing optimization', () => {
208+
const services = require('../../../../../lib/services');
209+
let getObjectListingStub;
210+
211+
beforeEach(() => {
212+
getObjectListingStub = sinon.stub(services, 'getObjectListing');
213+
});
214+
215+
afterEach(() => {
216+
if (getObjectListingStub) {
217+
getObjectListingStub.restore();
218+
}
219+
});
220+
221+
it('should use optimized prefix when listing versions', (done) => {
222+
createBucketAndMPU(true, (err, uploadId) => {
223+
assert.ifError(err);
224+
225+
// Mock version listing response - return empty to simulate no cleanup needed
226+
getObjectListingStub.yields(null, {
227+
Versions: [],
228+
IsTruncated: false
229+
});
230+
231+
abortMultipartUpload(authInfo, bucketName, objectKey, uploadId, log, (err) => {
232+
assert.strictEqual(err, null);
233+
234+
// Check if version listing was called with the correct prefix
235+
const versionListingCalls = getObjectListingStub.getCalls().filter(call =>
236+
call.args[1] && call.args[1].listingType === 'DelimiterVersions'
237+
);
238+
239+
if (versionListingCalls.length > 0) {
240+
// If version listing was called, verify the prefix optimization
241+
const expectedPrefix = `${objectKey}\u0000`; // objectKey + VersionId separator
242+
assert.strictEqual(versionListingCalls[0].args[1].prefix, expectedPrefix);
243+
assert.strictEqual(versionListingCalls[0].args[1].maxKeys, 1000);
244+
}
245+
246+
done();
247+
}, abortRequest);
248+
});
249+
});
250+
251+
it('should handle version listing pagination', (done) => {
252+
createBucketAndMPU(true, (err, uploadId) => {
253+
assert.ifError(err);
254+
255+
// First page - no match, truncated
256+
getObjectListingStub.onFirstCall().yields(null, {
257+
Versions: [
258+
{
259+
key: objectKey,
260+
value: { uploadId: 'different-upload-id', versionId: 'version-456' }
261+
}
262+
],
263+
IsTruncated: true,
264+
NextKeyMarker: objectKey,
265+
NextVersionIdMarker: 'version-456'
266+
});
267+
268+
// Second page - no match, not truncated
269+
getObjectListingStub.onSecondCall().yields(null, {
270+
Versions: [],
271+
IsTruncated: false
272+
});
273+
274+
abortMultipartUpload(authInfo, bucketName, objectKey, uploadId, log, (err) => {
275+
assert.strictEqual(err, null);
276+
277+
// Check if pagination markers were used correctly
278+
const versionListingCalls = getObjectListingStub.getCalls().filter(call =>
279+
call.args[1] && call.args[1].listingType === 'DelimiterVersions'
280+
);
281+
282+
if (versionListingCalls.length >= 2) {
283+
// First call should not have markers
284+
assert.strictEqual(versionListingCalls[0].args[1].keyMarker, undefined);
285+
assert.strictEqual(versionListingCalls[0].args[1].versionIdMarker, undefined);
286+
287+
// Second call should have markers from first response
288+
assert.strictEqual(versionListingCalls[1].args[1].keyMarker, objectKey);
289+
assert.strictEqual(versionListingCalls[1].args[1].versionIdMarker, 'version-456');
290+
}
291+
292+
done();
293+
}, abortRequest);
294+
});
295+
});
296+
297+
it('should handle version listing errors gracefully', (done) => {
298+
createBucketAndMPU(true, (err, uploadId) => {
299+
assert.ifError(err);
300+
301+
// Simulate error during version listing
302+
getObjectListingStub.yields(errors.InternalError);
303+
304+
abortMultipartUpload(authInfo, bucketName, objectKey, uploadId, log, (err) => {
305+
assert.strictEqual(err, null); // Should continue despite version listing error
306+
done();
307+
}, abortRequest);
308+
});
309+
});
310+
});
311+
312+
describe('error handling', () => {
313+
it('should handle external data abort error', (done) => {
314+
dataAbortMPUStub.restore();
315+
sinon.stub(data, 'abortMPU').callsFake((objectKey, uploadId, location, bucketName, request, destBucket, locationConstraintCheckFn, log, callback) => {
316+
callback(errors.InternalError);
317+
});
318+
319+
createBucketAndMPU(false, (err, uploadId) => {
320+
assert.ifError(err);
321+
322+
abortMultipartUpload(authInfo, bucketName, objectKey, uploadId, log, (err) => {
323+
assert(err);
324+
assert.strictEqual(err.is.InternalError, true);
325+
done();
326+
}, abortRequest);
327+
});
328+
});
329+
330+
it('should continue despite data deletion errors', (done) => {
331+
dataDeleteStub.restore();
332+
sinon.stub(data, 'delete').yields(errors.InternalError); // Fail data deletion
333+
334+
createBucketAndMPU(false, (err, uploadId) => {
335+
assert.ifError(err);
336+
337+
// Add a part so there's data to delete
338+
const md5Hash = require('crypto').createHash('md5');
339+
md5Hash.update(postBody);
340+
const calculatedHash = md5Hash.digest('hex');
341+
342+
const partRequest = new DummyRequest({
343+
bucketName,
344+
objectKey,
345+
namespace: 'default',
346+
url: `/${objectKey}?partNumber=1&uploadId=${uploadId}`,
347+
headers: { host: `${bucketName}.s3.amazonaws.com` },
348+
query: {
349+
partNumber: '1',
350+
uploadId,
351+
},
352+
calculatedHash,
353+
}, postBody);
354+
355+
objectPutPart(authInfo, partRequest, undefined, log, (err) => {
356+
assert.ifError(err);
357+
358+
abortMultipartUpload(authInfo, bucketName, objectKey, uploadId, log, (err) => {
359+
assert.strictEqual(err, null); // Should succeed despite data deletion failure
360+
done();
361+
}, abortRequest);
362+
});
363+
});
364+
});
365+
});
366+
});

0 commit comments

Comments
 (0)