Skip to content

Commit 1de6119

Browse files
authored
Merge pull request #638 from internxt/feat/fix-etag-management
[_]: fix/etag-management
2 parents 202c6bb + 2276fbd commit 1de6119

10 files changed

Lines changed: 130 additions & 29 deletions

File tree

src/utils/webdav.utils.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import path from 'node:path';
2+
import { createHash } from 'node:crypto';
23
import { WebDavRequestedResource } from '../types/webdav.types';
34
import { DriveFileItem, DriveFolderItem, DriveItem } from '../types/drive.types';
45
import { DriveItemService } from '../services/drive/drive-item.service';
@@ -109,4 +110,21 @@ export class WebDavUtils {
109110
}
110111
await DriveItemRepository.instance.delete([driveItem.uuid]);
111112
}
113+
114+
static generateETag(parts: Array<string | number | null | undefined>): string {
115+
const normalized = parts.map((part) => part ?? '-');
116+
const hash = createHash('sha256').update(normalized.join('|')).digest('hex');
117+
return `"${hash}"`;
118+
}
119+
120+
static getItemETag(driveItem: DriveFileItem | DriveFolderItem): string {
121+
return this.generateETag([
122+
driveItem.uuid,
123+
driveItem.itemType === 'file' ? driveItem.size : undefined,
124+
driveItem.createdAt.getTime(),
125+
driveItem.updatedAt.getTime(),
126+
driveItem.creationTime.getTime(),
127+
driveItem.modificationTime.getTime(),
128+
]);
129+
}
112130
}

src/webdav/handlers/GET.handler.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ export class GETRequestHandler implements WebDavMethodHandler {
2828

2929
res.header('Content-Type', 'application/octet-stream');
3030
res.header('Accept-Ranges', 'bytes');
31+
res.header('ETag', WebDavUtils.getItemETag(driveFile));
3132

3233
const fileSize = driveFile.size ?? 0;
3334

src/webdav/handlers/HEAD.handler.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ export class HEADRequestHandler implements WebDavMethodHandler {
1919

2020
webdavLogger.info(`[HEAD] [${driveItem.uuid}] Found Drive item`);
2121

22+
res.header('ETag', WebDavUtils.getItemETag(driveItem));
23+
2224
if (driveItem.itemType === 'file') {
2325
const range = req.headers['range'];
2426
const rangeOptions = NetworkUtils.parseRangeHeader({

src/webdav/handlers/PROPFIND.handler.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import { DriveFolderService } from '../../services/drive/drive-folder.service';
77
import { DriveUtils } from '../../utils/drive.utils';
88
import { FormatUtils } from '../../utils/format.utils';
99
import { Request, Response } from 'express';
10-
import { randomUUID } from 'node:crypto';
1110
import mime from 'mime-types';
1211
import { WebDavUtils } from '../../utils/webdav.utils';
1312
import { webdavLogger } from '../../utils/logger.utils';
@@ -188,7 +187,7 @@ export class PROPFINDRequestHandler implements WebDavMethodHandler {
188187
[XMLUtils.addDefaultNamespace('status')]: 'HTTP/1.1 200 OK',
189188
[XMLUtils.addDefaultNamespace('prop')]: {
190189
[XMLUtils.addDefaultNamespace('getcontenttype')]: 'httpd/unix-directory',
191-
[XMLUtils.addDefaultNamespace('getetag')]: '"' + randomUUID().replaceAll('-', '') + '"',
190+
[XMLUtils.addDefaultNamespace('getetag')]: WebDavUtils.getItemETag(driveFolderItem),
192191
'x1:lastmodified': {
193192
'#text': FormatUtils.formatDateForWebDav(driveFolderItem.updatedAt),
194193
'@_xmlns:x1': 'SAR:',
@@ -224,7 +223,7 @@ export class PROPFINDRequestHandler implements WebDavMethodHandler {
224223
[XMLUtils.addDefaultNamespace('getlastmodified')]: FormatUtils.formatDateForWebDav(driveFolderItem.updatedAt),
225224
[XMLUtils.addDefaultNamespace('getcontentlength')]: 0,
226225
[XMLUtils.addDefaultNamespace('getcontenttype')]: 'httpd/unix-directory',
227-
[XMLUtils.addDefaultNamespace('getetag')]: '"' + randomUUID().replaceAll('-', '') + '"',
226+
[XMLUtils.addDefaultNamespace('getetag')]: WebDavUtils.getItemETag(driveFolderItem),
228227
[XMLUtils.addDefaultNamespace('resourcetype')]: {
229228
[XMLUtils.addDefaultNamespace('collection')]: '',
230229
},
@@ -245,7 +244,7 @@ export class PROPFINDRequestHandler implements WebDavMethodHandler {
245244
[XMLUtils.addDefaultNamespace('status')]: 'HTTP/1.1 200 OK',
246245
[XMLUtils.addDefaultNamespace('prop')]: {
247246
[XMLUtils.addDefaultNamespace('resourcetype')]: '',
248-
[XMLUtils.addDefaultNamespace('getetag')]: '"' + randomUUID().replaceAll('-', '') + '"',
247+
[XMLUtils.addDefaultNamespace('getetag')]: WebDavUtils.getItemETag(driveFileItem),
249248
[XMLUtils.addDefaultNamespace('displayname')]: displayName,
250249
[XMLUtils.addDefaultNamespace('getcontenttype')]: mime.lookup(displayName) || 'application/octet-stream',
251250
[XMLUtils.addDefaultNamespace('getlastmodified')]: lastModified,

src/webdav/handlers/PUT.handler.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,7 @@ export class PUTRequestHandler implements WebDavMethodHandler {
166166
`after ${CLIUtils.formatDuration(totalTime)}`,
167167
);
168168

169+
res.header('ETag', WebDavUtils.getItemETag(file));
169170
res.status(statusCode).send();
170171
};
171172
}

test/utils/webdav.utils.test.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,84 @@ describe('Webdav utils', () => {
166166
});
167167
});
168168

169+
describe('generateETag', () => {
170+
test('when the same parts are given, then the same etag is generated', () => {
171+
const date = new Date('2024-03-04T15:11:01.000Z');
172+
const etag1 = WebDavUtils.generateETag(['uuid-1', 100, date.getTime()]);
173+
const etag2 = WebDavUtils.generateETag(['uuid-1', 100, date.getTime()]);
174+
175+
expect(etag1).to.be.equal(etag2);
176+
});
177+
178+
test('that etag is wrapped with double quotes', () => {
179+
const etag = WebDavUtils.generateETag(['uuid-1']);
180+
181+
expect(etag.startsWith('"')).toBe(true);
182+
expect(etag.endsWith('"')).toBe(true);
183+
});
184+
185+
test('when any part differs, then a different etag is generated', () => {
186+
const date = new Date('2024-03-04T15:11:01.000Z');
187+
const baseEtag = WebDavUtils.generateETag(['uuid-1', 100, date.getTime()]);
188+
189+
expect(WebDavUtils.generateETag(['uuid-2', 100, date.getTime()])).to.not.be.equal(baseEtag);
190+
expect(WebDavUtils.generateETag(['uuid-1', 200, date.getTime()])).to.not.be.equal(baseEtag);
191+
expect(WebDavUtils.generateETag(['uuid-1', 100, new Date('2024-03-04T15:11:02.000Z').getTime()])).to.not.be.equal(
192+
baseEtag,
193+
);
194+
});
195+
196+
test('when a Date is given, then it is normalized using its timestamp', () => {
197+
const date = new Date('2024-03-04T15:11:01.000Z');
198+
const etagFromDate = WebDavUtils.generateETag(['uuid-1', date.getTime()]);
199+
const etagFromTimestamp = WebDavUtils.generateETag(['uuid-1', date.getTime()]);
200+
201+
expect(etagFromDate).to.be.equal(etagFromTimestamp);
202+
});
203+
204+
test('when null or undefined parts are given, then they are treated as equal empty values', () => {
205+
const etagFromNull = WebDavUtils.generateETag(['uuid-1', null]);
206+
const etagFromUndefined = WebDavUtils.generateETag(['uuid-1', undefined]);
207+
208+
expect(etagFromNull).to.be.equal(etagFromUndefined);
209+
});
210+
});
211+
212+
describe('getItemETag', () => {
213+
test('when the same file is given, then the same etag is generated', () => {
214+
const fileItem = newFileItem();
215+
216+
expect(WebDavUtils.getItemETag(fileItem)).to.be.equal(WebDavUtils.getItemETag(fileItem));
217+
});
218+
219+
test('when a file changes size, then the etag changes', () => {
220+
const fileItem = newFileItem({ size: 100 });
221+
const resizedFileItem = { ...fileItem, size: 200 };
222+
223+
expect(WebDavUtils.getItemETag(resizedFileItem)).to.not.be.equal(WebDavUtils.getItemETag(fileItem));
224+
});
225+
226+
test('when a file changes modificationTime, then the etag changes', () => {
227+
const fileItem = newFileItem({ modificationTime: new Date('2024-01-01T00:00:00.000Z') });
228+
const touchedFileItem = { ...fileItem, modificationTime: new Date('2024-02-02T00:00:00.000Z') };
229+
230+
expect(WebDavUtils.getItemETag(touchedFileItem)).to.not.be.equal(WebDavUtils.getItemETag(fileItem));
231+
});
232+
233+
test('when two folders share uuid and dates, then they get the same etag regardless of size', () => {
234+
const folderItem = newFolderItem();
235+
236+
expect(WebDavUtils.getItemETag(folderItem)).to.be.equal(WebDavUtils.getItemETag({ ...folderItem }));
237+
});
238+
239+
test('when two items have different uuids, then they get different etags', () => {
240+
const fileItem = newFileItem({ uuid: 'uuid-1' });
241+
const otherFileItem = { ...fileItem, uuid: 'uuid-2' };
242+
243+
expect(WebDavUtils.getItemETag(fileItem)).to.not.be.equal(WebDavUtils.getItemETag(otherFileItem));
244+
});
245+
});
246+
169247
describe('deleteOrTrashItem', () => {
170248
test('when permanent deletion is enabled for files, then files are deleted permanently and cache is cleared', async () => {
171249
const fileItem = newFileItem();

test/webdav/handlers/GET.handler.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ describe('GET request handler', () => {
9292
expect(response.status).toHaveBeenCalledWith(200);
9393
expect(response.header).toHaveBeenCalledWith('Content-length', mockFile.size.toString());
9494
expect(response.header).toHaveBeenCalledWith('Content-Type', 'application/octet-stream');
95+
expect(response.header).toHaveBeenCalledWith('ETag', WebDavUtils.getItemETag(mockFile));
9596
expect(getRequestedResourceStub).toHaveBeenCalledOnce();
9697
expect(getFileMetadataStub).toHaveBeenCalledOnce();
9798
expect(authDetailsStub).toHaveBeenCalledOnce();
@@ -147,6 +148,7 @@ describe('GET request handler', () => {
147148
expect(response.status).toHaveBeenCalledWith(200);
148149
expect(response.header).toHaveBeenCalledWith('Content-length', (mockSize - rangeStart).toString());
149150
expect(response.header).toHaveBeenCalledWith('Content-Type', 'application/octet-stream');
151+
expect(response.header).toHaveBeenCalledWith('ETag', WebDavUtils.getItemETag(mockFile));
150152
expect(getRequestedResourceStub).toHaveBeenCalledOnce();
151153
expect(getFileMetadataStub).toHaveBeenCalledOnce();
152154
expect(authDetailsStub).toHaveBeenCalledOnce();
@@ -190,6 +192,7 @@ describe('GET request handler', () => {
190192
expect(response.status).toHaveBeenCalledWith(200);
191193
expect(response.header).toHaveBeenCalledWith('Content-length', Number(0).toString());
192194
expect(response.header).toHaveBeenCalledWith('Content-Type', 'application/octet-stream');
195+
expect(response.header).toHaveBeenCalledWith('ETag', WebDavUtils.getItemETag(mockFile));
193196
expect(getRequestedResourceStub).toHaveBeenCalledOnce();
194197
expect(getFileMetadataStub).toHaveBeenCalledOnce();
195198
expect(authDetailsStub).toHaveBeenCalledOnce();

test/webdav/handlers/HEAD.handler.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ describe('HEAD request handler', () => {
4040

4141
await sut.handle(request, response);
4242
expect(response.status).toHaveBeenCalledWith(200);
43+
expect(response.header).toHaveBeenCalledWith('ETag', WebDavUtils.getItemETag(mockFolder));
4344
expect(getRequestedResourceStub).toHaveBeenCalledOnce();
4445
expect(getFolderMetadataStub).toHaveBeenCalledOnce();
4546
});
@@ -68,6 +69,7 @@ describe('HEAD request handler', () => {
6869
expect(response.status).toHaveBeenCalledWith(200);
6970
expect(response.header).toHaveBeenCalledWith('Content-Type', 'application/octet-stream');
7071
expect(response.header).toHaveBeenCalledWith('Content-length', mockFile.size.toString());
72+
expect(response.header).toHaveBeenCalledWith('ETag', WebDavUtils.getItemETag(mockFile));
7173
expect(getRequestedResourceStub).toHaveBeenCalledOnce();
7274
expect(getFileMetadataStub).toHaveBeenCalledOnce();
7375
});
@@ -100,6 +102,7 @@ describe('HEAD request handler', () => {
100102
expect(response.status).toHaveBeenCalledWith(200);
101103
expect(response.header).toHaveBeenCalledWith('Content-length', (mockSize - rangeStart).toString());
102104
expect(response.header).toHaveBeenCalledWith('Content-Type', 'application/octet-stream');
105+
expect(response.header).toHaveBeenCalledWith('ETag', WebDavUtils.getItemETag(mockFile));
103106
expect(getRequestedResourceStub).toHaveBeenCalledOnce();
104107
expect(getFileMetadataStub).toHaveBeenCalledOnce();
105108
});

test/webdav/handlers/PROPFIND.handler.test.ts

Lines changed: 18 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -15,19 +15,14 @@ import { FormatUtils } from '../../../src/utils/format.utils';
1515
import { WebDavRequestedResource } from '../../../src/types/webdav.types';
1616
import { WebDavUtils } from '../../../src/utils/webdav.utils';
1717
import mime from 'mime-types';
18-
import crypto, { randomUUID } from 'node:crypto';
18+
import crypto from 'node:crypto';
1919
import { UsageService } from '../../../src/services/usage.service';
2020
import { XMLUtils } from '../../../src/utils/xml.utils';
21+
import { DriveFileItem, DriveFolderItem } from '../../../src/types/drive.types';
2122

22-
vi.mock('node:crypto', async () => {
23-
const actual = await vi.importActual<typeof import('node:crypto')>('node:crypto');
24-
return {
25-
...(actual as object),
26-
randomUUID: vi.fn().mockImplementation(actual.randomUUID),
27-
};
28-
});
29-
30-
const randomUUIDStub = vi.mocked(randomUUID);
23+
const getExpectedETag = (item: DriveFileItem | DriveFolderItem): string => {
24+
return WebDavUtils.getItemETag(item).replaceAll('"', '');
25+
};
3126

3227
describe('PROPFIND request handler', () => {
3328
let sut: PROPFINDRequestHandler;
@@ -57,11 +52,8 @@ describe('PROPFIND request handler', () => {
5752
});
5853
const usageFixture = crypto.randomInt(2000000000);
5954
const spaceLimitFixture = crypto.randomInt(2000000000);
60-
const uuidFixture = 'test-test-test-test-test';
61-
const etagFixture = uuidFixture.replaceAll('-', '');
55+
const etagFixture = getExpectedETag(folderFixture);
6256

63-
randomUUIDStub.mockClear();
64-
randomUUIDStub.mockImplementation(() => uuidFixture);
6557
const getRequestedResourceStub = vi
6658
.spyOn(WebDavUtils, 'getRequestedResource')
6759
.mockResolvedValue(requestedFolderResource);
@@ -141,11 +133,16 @@ describe('PROPFIND request handler', () => {
141133
});
142134
const usageFixture = crypto.randomInt(2000000000);
143135
const spaceLimitFixture = crypto.randomInt(2000000000);
144-
const uuidFixture = 'test-test-test-test-test';
145-
const etagFixture = uuidFixture.replaceAll('-', '');
146-
147-
randomUUIDStub.mockClear();
148-
randomUUIDStub.mockImplementation(() => uuidFixture);
136+
const etagFixture = getExpectedETag(folderFixture);
137+
const childEtagFixture = getExpectedETag(
138+
newFolderItem({
139+
uuid: paginatedFolder1.uuid,
140+
createdAt: new Date(paginatedFolder1.createdAt),
141+
updatedAt: new Date(paginatedFolder1.updatedAt),
142+
creationTime: new Date(paginatedFolder1.creationTime),
143+
modificationTime: new Date(paginatedFolder1.modificationTime),
144+
}),
145+
);
149146

150147
const getRequestedResourceStub = vi
151148
.spyOn(WebDavUtils, 'getRequestedResource')
@@ -163,7 +160,7 @@ describe('PROPFIND request handler', () => {
163160
await sut.handle(request, response);
164161
expect(response.status).toHaveBeenCalledWith(207);
165162
expect(response.send).toHaveBeenCalledWith(
166-
`<?xml version="1.0" encoding="utf-8" ?><D:multistatus xmlns:D="DAV:"><D:response><D:href>${XMLUtils.encodeWebDavUri('/')}</D:href><D:propstat><D:status>HTTP/1.1 200 OK</D:status><D:prop><D:getcontenttype>httpd/unix-directory</D:getcontenttype><D:getetag>&quot;${etagFixture}&quot;</D:getetag><x1:lastmodified xmlns:x1="SAR:">${FormatUtils.formatDateForWebDav(folderFixture.updatedAt)}</x1:lastmodified><x2:executable xmlns:x2="http://apache.org/dav/props/">F</x2:executable><x3:Win32FileAttributes xmlns:x3="urn:schemas-microsoft-com:">00000030</x3:Win32FileAttributes><D:quota-available-bytes>${spaceLimitFixture - usageFixture}</D:quota-available-bytes><D:quota-used-bytes>${usageFixture}</D:quota-used-bytes><D:resourcetype><D:collection/></D:resourcetype></D:prop></D:propstat></D:response><D:response><D:href>${XMLUtils.encodeWebDavUri(`/${paginatedFolder1.plainName}/`)}</D:href><D:propstat><D:status>HTTP/1.1 200 OK</D:status><D:prop><D:displayname>${paginatedFolder1.plainName}</D:displayname><D:getlastmodified>${FormatUtils.formatDateForWebDav(paginatedFolder1.updatedAt)}</D:getlastmodified><D:getcontentlength>0</D:getcontentlength><D:getcontenttype>httpd/unix-directory</D:getcontenttype><D:getetag>&quot;${etagFixture}&quot;</D:getetag><D:resourcetype><D:collection/></D:resourcetype></D:prop></D:propstat></D:response></D:multistatus>`,
163+
`<?xml version="1.0" encoding="utf-8" ?><D:multistatus xmlns:D="DAV:"><D:response><D:href>${XMLUtils.encodeWebDavUri('/')}</D:href><D:propstat><D:status>HTTP/1.1 200 OK</D:status><D:prop><D:getcontenttype>httpd/unix-directory</D:getcontenttype><D:getetag>&quot;${etagFixture}&quot;</D:getetag><x1:lastmodified xmlns:x1="SAR:">${FormatUtils.formatDateForWebDav(folderFixture.updatedAt)}</x1:lastmodified><x2:executable xmlns:x2="http://apache.org/dav/props/">F</x2:executable><x3:Win32FileAttributes xmlns:x3="urn:schemas-microsoft-com:">00000030</x3:Win32FileAttributes><D:quota-available-bytes>${spaceLimitFixture - usageFixture}</D:quota-available-bytes><D:quota-used-bytes>${usageFixture}</D:quota-used-bytes><D:resourcetype><D:collection/></D:resourcetype></D:prop></D:propstat></D:response><D:response><D:href>${XMLUtils.encodeWebDavUri(`/${paginatedFolder1.plainName}/`)}</D:href><D:propstat><D:status>HTTP/1.1 200 OK</D:status><D:prop><D:displayname>${paginatedFolder1.plainName}</D:displayname><D:getlastmodified>${FormatUtils.formatDateForWebDav(paginatedFolder1.updatedAt)}</D:getlastmodified><D:getcontentlength>0</D:getcontentlength><D:getcontenttype>httpd/unix-directory</D:getcontenttype><D:getetag>&quot;${childEtagFixture}&quot;</D:getetag><D:resourcetype><D:collection/></D:resourcetype></D:prop></D:propstat></D:response></D:multistatus>`,
167164
);
168165
expect(getRequestedResourceStub).toHaveBeenCalledOnce();
169166
expect(getAndSearchItemFromResourceStub).toHaveBeenCalledOnce();
@@ -224,8 +221,7 @@ describe('PROPFIND request handler', () => {
224221
});
225222

226223
const fileFixture = newFileItem({ name: 'file', type: 'png' });
227-
const uuidFixture = 'test-test-test-test-test';
228-
const etagFixture = uuidFixture.replaceAll('-', '');
224+
const etagFixture = getExpectedETag(fileFixture);
229225
const mimeFixture = 'image/png';
230226

231227
const getRequestedResourceStub = vi
@@ -234,8 +230,6 @@ describe('PROPFIND request handler', () => {
234230
const getAndSearchItemFromResourceStub = vi
235231
.spyOn(WebDavUtils, 'getDriveItemFromResource')
236232
.mockResolvedValue(fileFixture);
237-
randomUUIDStub.mockClear();
238-
randomUUIDStub.mockImplementation(() => uuidFixture);
239233
const mimeLookupStub = vi.spyOn(mime, 'lookup').mockReturnValue(mimeFixture);
240234

241235
await sut.handle(request, response);
@@ -245,7 +239,6 @@ describe('PROPFIND request handler', () => {
245239
);
246240
expect(getRequestedResourceStub).toHaveBeenCalledOnce();
247241
expect(getAndSearchItemFromResourceStub).toHaveBeenCalledOnce();
248-
expect(randomUUIDStub).toHaveBeenCalledOnce();
249242
expect(mimeLookupStub).toHaveBeenCalledOnce();
250243
});
251244

test/webdav/handlers/PUT.handler.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ describe('PUT request handler', () => {
7676

7777
await sut.handle(request, response);
7878
expect(response.status).toHaveBeenCalledWith(201);
79+
expect(response.header).toHaveBeenCalledWith('ETag', WebDavUtils.getItemETag(fileFixture));
7980
expect(getRequestedResourceStub).toHaveBeenCalledTimes(2);
8081
expect(getAndSearchItemFromResourceStub).toHaveBeenCalledOnce();
8182
expect(getDriveFolderFromResourceStub).toHaveBeenCalledOnce();
@@ -123,6 +124,7 @@ describe('PUT request handler', () => {
123124

124125
await sut.handle(request, response);
125126
expect(response.status).toHaveBeenCalledWith(201);
127+
expect(response.header).toHaveBeenCalledWith('ETag', WebDavUtils.getItemETag(fileFixture));
126128
expect(getRequestedResourceStub).toHaveBeenCalledTimes(2);
127129
expect(getAndSearchItemFromResourceStub).toHaveBeenCalledOnce();
128130
expect(getDriveFolderFromResourceStub).toHaveBeenCalledOnce();
@@ -172,6 +174,7 @@ describe('PUT request handler', () => {
172174

173175
await sut.handle(request, response);
174176
expect(response.status).toHaveBeenCalledWith(204);
177+
expect(response.header).toHaveBeenCalledWith('ETag', WebDavUtils.getItemETag(fileFixture));
175178
expect(getRequestedResourceStub).toHaveBeenCalledTimes(2);
176179
expect(getAndSearchItemFromResourceStub).toHaveBeenCalledOnce();
177180
expect(getDriveFolderFromResourceStub).toHaveBeenCalledOnce();

0 commit comments

Comments
 (0)