www

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs | Submodules | README | LICENSE

webdavTest.js (28748B)


      1 "use strict";
      2 
      3 describe("Zotero.Sync.Storage.Mode.WebDAV", function () {
      4 	//
      5 	// Setup
      6 	//
      7 	Components.utils.import("resource://zotero-unit/httpd.js");
      8 	
      9 	var davScheme = "http";
     10 	var davPort = 16214;
     11 	var davBasePath = "/webdav/";
     12 	var davHostPath = `localhost:${davPort}${davBasePath}`;
     13 	var davUsername = "user";
     14 	var davPassword = "password";
     15 	var davURL = `${davScheme}://${davUsername}:${davPassword}@${davHostPath}`;
     16 	
     17 	var win, controller, server, requestCount;
     18 	var responses = {};
     19 	
     20 	function setResponse(response) {
     21 		setHTTPResponse(server, davURL, response, responses);
     22 	}
     23 	
     24 	function resetRequestCount() {
     25 		requestCount = server.requests.filter(r => r.responseHeaders["Fake-Server-Match"]).length;
     26 	}
     27 	
     28 	function assertRequestCount(count) {
     29 		assert.equal(
     30 			server.requests.filter(r => r.responseHeaders["Fake-Server-Match"]).length - requestCount,
     31 			count
     32 		);
     33 	}
     34 	
     35 	function generateLastSyncID() {
     36 		return "" + Zotero.Utilities.randomString(controller._lastSyncIDLength);
     37 	}
     38 	
     39 	function parseQueryString(str) {
     40 		var queryStringParams = str.split('&');
     41 		var params = {};
     42 		for (let param of queryStringParams) {
     43 			let [ key, val ] = param.split('=');
     44 			params[key] = decodeURIComponent(val);
     45 		}
     46 		return params;
     47 	}
     48 	
     49 	beforeEach(function* () {
     50 		yield resetDB({
     51 			thisArg: this,
     52 			skipBundledFiles: true
     53 		});
     54 		
     55 		Zotero.HTTP.mock = sinon.FakeXMLHttpRequest;
     56 		server = sinon.fakeServer.create();
     57 		server.autoRespond = true;
     58 		
     59 		this.httpd = new HttpServer();
     60 		this.httpd.start(davPort);
     61 		
     62 		yield Zotero.Users.setCurrentUserID(1);
     63 		yield Zotero.Users.setCurrentUsername("testuser");
     64 		
     65 		Zotero.Sync.Storage.Local.setModeForLibrary(Zotero.Libraries.userLibraryID, 'webdav');
     66 		controller = new Zotero.Sync.Storage.Mode.WebDAV;
     67 		Zotero.Prefs.set("sync.storage.scheme", davScheme);
     68 		Zotero.Prefs.set("sync.storage.url", davHostPath);
     69 		Zotero.Prefs.set("sync.storage.username", davUsername);
     70 		controller.password = davPassword;
     71 		
     72 		// Set download-on-sync by default
     73 		Zotero.Sync.Storage.Local.downloadOnSync(
     74 			Zotero.Libraries.userLibraryID, true
     75 		);
     76 	})
     77 	
     78 	var setup = Zotero.Promise.coroutine(function* (options = {}) {
     79 		var engine = new Zotero.Sync.Storage.Engine({
     80 			libraryID: options.libraryID || Zotero.Libraries.userLibraryID,
     81 			controller,
     82 			stopOnError: true
     83 		});
     84 		
     85 		if (!controller.verified) {
     86 			setResponse({
     87 				method: "OPTIONS",
     88 				url: "zotero/",
     89 				headers: {
     90 					DAV: 1
     91 				},
     92 				status: 200
     93 			})
     94 			setResponse({
     95 				method: "PROPFIND",
     96 				url: "zotero/",
     97 				status: 207
     98 			})
     99 			setResponse({
    100 				method: "PUT",
    101 				url: "zotero/zotero-test-file.prop",
    102 				status: 201
    103 			})
    104 			setResponse({
    105 				method: "GET",
    106 				url: "zotero/zotero-test-file.prop",
    107 				status: 200
    108 			})
    109 			setResponse({
    110 				method: "DELETE",
    111 				url: "zotero/zotero-test-file.prop",
    112 				status: 200
    113 			})
    114 			yield controller.checkServer();
    115 			
    116 			yield controller.cacheCredentials();
    117 		}
    118 		
    119 		resetRequestCount();
    120 		
    121 		return engine;
    122 	})
    123 	
    124 	afterEach(function* () {
    125 		var defer = new Zotero.Promise.defer();
    126 		this.httpd.stop(() => defer.resolve());
    127 		yield defer.promise;
    128 	})
    129 	
    130 	after(function* () {
    131 		Zotero.HTTP.mock = null;
    132 		if (win) {
    133 			win.close();
    134 		}
    135 	})
    136 	
    137 	
    138 	//
    139 	// Tests
    140 	//
    141 	describe("Syncing", function () {
    142 		beforeEach(function* () {
    143 			win = yield loadZoteroPane();
    144 		})
    145 		
    146 		afterEach(function () {
    147 			win.close();
    148 		})
    149 		
    150 		it("should skip downloads if not marked as needed", function* () {
    151 			var engine = yield setup();
    152 			
    153 			var library = Zotero.Libraries.userLibrary;
    154 			library.libraryVersion = 5;
    155 			yield library.saveTx();
    156 			
    157 			var result = yield engine.start();
    158 			
    159 			assertRequestCount(0);
    160 			
    161 			assert.isFalse(result.localChanges);
    162 			assert.isFalse(result.remoteChanges);
    163 			assert.isFalse(result.syncRequired);
    164 			
    165 			assert.equal(library.storageVersion, library.libraryVersion);
    166 		})
    167 		
    168 		it("should ignore a remotely missing file", function* () {
    169 			var engine = yield setup();
    170 			
    171 			var library = Zotero.Libraries.userLibrary;
    172 			library.libraryVersion = 5;
    173 			yield library.saveTx();
    174 			library.storageDownloadNeeded = true;
    175 			
    176 			var item = new Zotero.Item("attachment");
    177 			item.attachmentLinkMode = 'imported_file';
    178 			item.attachmentPath = 'storage:test.txt';
    179 			item.attachmentSyncState = "to_download";
    180 			yield item.saveTx();
    181 			
    182 			setResponse({
    183 				method: "GET",
    184 				url: `zotero/${item.key}.prop`,
    185 				status: 404
    186 			});
    187 			var result = yield engine.start();
    188 			
    189 			assertRequestCount(1);
    190 			
    191 			assert.isFalse(result.localChanges);
    192 			assert.isFalse(result.remoteChanges);
    193 			assert.isFalse(result.syncRequired);
    194 			
    195 			assert.isFalse(library.storageDownloadNeeded);
    196 			assert.equal(library.storageVersion, library.libraryVersion);
    197 		})
    198 		
    199 		it("should handle a remotely failing .prop file", function* () {
    200 			var engine = yield setup();
    201 			
    202 			var library = Zotero.Libraries.userLibrary;
    203 			library.libraryVersion = 5;
    204 			yield library.saveTx();
    205 			library.storageDownloadNeeded = true;
    206 			
    207 			var item = new Zotero.Item("attachment");
    208 			item.attachmentLinkMode = 'imported_file';
    209 			item.attachmentPath = 'storage:test.txt';
    210 			item.attachmentSyncState = "to_download";
    211 			yield item.saveTx();
    212 			
    213 			setResponse({
    214 				method: "GET",
    215 				url: `zotero/${item.key}.prop`,
    216 				status: 500
    217 			});
    218 			
    219 			// TODO: In stopOnError mode, the promise is rejected.
    220 			// This should probably test with stopOnError mode turned off instead.
    221 			var e = yield getPromiseError(engine.start());
    222 			assert.include(
    223 				e.message,
    224 				Zotero.getString('sync.storage.error.webdav.requestError', [500, "GET"])
    225 			);
    226 			
    227 			assertRequestCount(1);
    228 			
    229 			assert.isTrue(library.storageDownloadNeeded);
    230 			assert.equal(library.storageVersion, 0);
    231 		})
    232 		
    233 		it("should handle a remotely failing .zip file", function* () {
    234 			var engine = yield setup();
    235 			
    236 			var library = Zotero.Libraries.userLibrary;
    237 			library.libraryVersion = 5;
    238 			yield library.saveTx();
    239 			library.storageDownloadNeeded = true;
    240 			
    241 			var item = new Zotero.Item("attachment");
    242 			item.attachmentLinkMode = 'imported_file';
    243 			item.attachmentPath = 'storage:test.txt';
    244 			item.attachmentSyncState = "to_download";
    245 			yield item.saveTx();
    246 			
    247 			setResponse({
    248 				method: "GET",
    249 				url: `zotero/${item.key}.prop`,
    250 				status: 200,
    251 				text: '<properties version="1">'
    252 					+ '<mtime>1234567890</mtime>'
    253 					+ '<hash>8286300a280f64a4b5cfaac547c21d32</hash>'
    254 					+ '</properties>'
    255 			});
    256 			this.httpd.registerPathHandler(
    257 				`${davBasePath}zotero/${item.key}.zip`,
    258 				{
    259 					handle: function (request, response) {
    260 						response.setStatusLine(null, 500, null);
    261 					}
    262 				}
    263 			);
    264 			// TODO: In stopOnError mode, the promise is rejected.
    265 			// This should probably test with stopOnError mode turned off instead.
    266 			var e = yield getPromiseError(engine.start());
    267 			assert.include(
    268 				e.message,
    269 				Zotero.getString('sync.storage.error.webdav.requestError', [500, "GET"])
    270 			);
    271 			
    272 			assert.isTrue(library.storageDownloadNeeded);
    273 			assert.equal(library.storageVersion, 0);
    274 		})
    275 		
    276 		
    277 		it("should download a missing file", function* () {
    278 			var engine = yield setup();
    279 			
    280 			var library = Zotero.Libraries.userLibrary;
    281 			library.libraryVersion = 5;
    282 			yield library.saveTx();
    283 			library.storageDownloadNeeded = true;
    284 			
    285 			var fileName = "test.txt";
    286 			var item = new Zotero.Item("attachment");
    287 			item.attachmentLinkMode = 'imported_file';
    288 			item.attachmentPath = 'storage:' + fileName;
    289 			// TODO: Test binary data
    290 			var text = Zotero.Utilities.randomString();
    291 			item.attachmentSyncState = "to_download";
    292 			yield item.saveTx();
    293 			
    294 			// Create ZIP file containing above text file
    295 			var tmpPath = Zotero.getTempDirectory().path;
    296 			var tmpID = "webdav_download_" + Zotero.Utilities.randomString();
    297 			var zipDirPath = OS.Path.join(tmpPath, tmpID);
    298 			var zipPath = OS.Path.join(tmpPath, tmpID + ".zip");
    299 			yield OS.File.makeDir(zipDirPath);
    300 			yield Zotero.File.putContentsAsync(OS.Path.join(zipDirPath, fileName), text);
    301 			yield Zotero.File.zipDirectory(zipDirPath, zipPath);
    302 			yield OS.File.removeDir(zipDirPath);
    303 			yield Zotero.Promise.delay(1000);
    304 			var zipContents = yield Zotero.File.getBinaryContentsAsync(zipPath);
    305 			
    306 			var mtime = "1441252524905";
    307 			var md5 = yield Zotero.Utilities.Internal.md5Async(zipPath);
    308 			
    309 			yield OS.File.remove(zipPath);
    310 			
    311 			setResponse({
    312 				method: "GET",
    313 				url: `zotero/${item.key}.prop`,
    314 				status: 200,
    315 				text: '<properties version="1">'
    316 					+ `<mtime>${mtime}</mtime>`
    317 					+ `<hash>${md5}</hash>`
    318 					+ '</properties>'
    319 			});
    320 			this.httpd.registerPathHandler(
    321 				`${davBasePath}zotero/${item.key}.zip`,
    322 				{
    323 					handle: function (request, response) {
    324 						response.setStatusLine(null, 200, "OK");
    325 						response.write(zipContents);
    326 					}
    327 				}
    328 			);
    329 			
    330 			var result = yield engine.start();
    331 			
    332 			assert.isTrue(result.localChanges);
    333 			assert.isFalse(result.remoteChanges);
    334 			assert.isFalse(result.syncRequired);
    335 			
    336 			var contents = yield Zotero.File.getContentsAsync(yield item.getFilePathAsync());
    337 			assert.equal(contents, text);
    338 			
    339 			assert.isFalse(library.storageDownloadNeeded);
    340 			assert.equal(library.storageVersion, library.libraryVersion);
    341 		})
    342 		
    343 		it("should upload new files", function* () {
    344 			var engine = yield setup();
    345 			
    346 			var file = getTestDataDirectory();
    347 			file.append('test.png');
    348 			var item = yield Zotero.Attachments.importFromFile({ file });
    349 			item.synced = true;
    350 			yield item.saveTx();
    351 			var mtime = yield item.attachmentModificationTime;
    352 			var hash = yield item.attachmentHash;
    353 			var path = item.getFilePath();
    354 			var filename = 'test.png';
    355 			var size = (yield OS.File.stat(path)).size;
    356 			var contentType = 'image/png';
    357 			var fileContents = yield Zotero.File.getContentsAsync(path);
    358 			
    359 			var deferreds = [];
    360 			
    361 			setResponse({
    362 				method: "GET",
    363 				url: `zotero/${item.key}.prop`,
    364 				status: 404
    365 			});
    366 			// https://github.com/cjohansen/Sinon.JS/issues/607
    367 			let fixSinonBug = ";charset=utf-8";
    368 			server.respond(function (req) {
    369 				if (req.method == "PUT" && req.url == `${davURL}zotero/${item.key}.zip`) {
    370 					assert.equal(req.requestHeaders["Content-Type"], "application/zip" + fixSinonBug);
    371 					
    372 					let deferred = Zotero.Promise.defer();
    373 					deferreds.push(deferred);
    374 					var reader = new FileReader();
    375 					reader.addEventListener("loadend", Zotero.Promise.coroutine(function* () {
    376 						try {
    377 							let tmpZipPath = OS.Path.join(
    378 								Zotero.getTempDirectory().path,
    379 								Zotero.Utilities.randomString() + '.zip'
    380 							);
    381 							let file = yield OS.File.open(tmpZipPath, {
    382 								create: true
    383 							});
    384 							var contents = new Uint8Array(reader.result);
    385 							yield file.write(contents);
    386 							yield file.close();
    387 							
    388 							// Make sure ZIP file contains the necessary entries
    389 							var zr = Components.classes["@mozilla.org/libjar/zip-reader;1"]
    390 								.createInstance(Components.interfaces.nsIZipReader);
    391 							zr.open(Zotero.File.pathToFile(tmpZipPath));
    392 							zr.test(null);
    393 							var entries = zr.findEntries('*');
    394 							var entryNames = [];
    395 							while (entries.hasMore()) {
    396 								entryNames.push(entries.getNext());
    397 							}
    398 							assert.equal(entryNames.length, 1);
    399 							assert.sameMembers(entryNames, [filename]);
    400 							assert.equal(zr.getEntry(filename).realSize, size);
    401 							
    402 							yield OS.File.remove(tmpZipPath);
    403 							
    404 							deferred.resolve();
    405 						}
    406 						catch (e) {
    407 							deferred.reject(e);
    408 						}
    409 					}));
    410 					reader.readAsArrayBuffer(req.requestBody);
    411 					
    412 					req.respond(201, { "Fake-Server-Match": 1 }, "");
    413 				}
    414 				else if (req.method == "PUT" && req.url == `${davURL}zotero/${item.key}.prop`) {
    415 					var parser = Components.classes["@mozilla.org/xmlextras/domparser;1"]
    416 						.createInstance(Components.interfaces.nsIDOMParser);
    417 					var doc = parser.parseFromString(req.requestBody, "text/xml");
    418 					assert.equal(
    419 						doc.documentElement.getElementsByTagName('mtime')[0].textContent, mtime
    420 					);
    421 					assert.equal(
    422 						doc.documentElement.getElementsByTagName('hash')[0].textContent, hash
    423 					);
    424 					
    425 					req.respond(204, { "Fake-Server-Match": 1 }, "");
    426 				}
    427 			});
    428 			
    429 			var result = yield engine.start();
    430 			
    431 			yield Zotero.Promise.all(deferreds.map(d => d.promise));
    432 			
    433 			assertRequestCount(3);
    434 			
    435 			assert.isTrue(result.localChanges);
    436 			assert.isTrue(result.remoteChanges);
    437 			assert.isTrue(result.syncRequired);
    438 			
    439 			// Check local objects
    440 			assert.equal(item.attachmentSyncedModificationTime, mtime);
    441 			assert.equal(item.attachmentSyncedHash, hash);
    442 			assert.isFalse(item.synced);
    443 		})
    444 		
    445 		it("should upload an updated file", function* () {
    446 			var engine = yield setup();
    447 			
    448 			var file = getTestDataDirectory();
    449 			file.append('test.txt');
    450 			var item = yield Zotero.Attachments.importFromFile({ file });
    451 			item.synced = true;
    452 			yield item.saveTx();
    453 			
    454 			var syncedModTime = Date.now() - 10000;
    455 			var syncedHash = "3a2f092dd62178eb8bbfda42e07e64da";
    456 			
    457 			item.attachmentSyncedModificationTime = syncedModTime;
    458 			item.attachmentSyncedHash = syncedHash;
    459 			yield item.saveTx({ skipAll: true });
    460 			
    461 			var mtime = yield item.attachmentModificationTime;
    462 			var hash = yield item.attachmentHash;
    463 			
    464 			setResponse({
    465 				method: "GET",
    466 				url: `zotero/${item.key}.prop`,
    467 				text: '<properties version="1">'
    468 					+ `<mtime>${syncedModTime}</mtime>`
    469 					+ `<hash>${syncedHash}</hash>`
    470 					+ '</properties>'
    471 			});
    472 			setResponse({
    473 				method: "DELETE",
    474 				url: `zotero/${item.key}.prop`,
    475 				status: 204
    476 			});
    477 			setResponse({
    478 				method: "PUT",
    479 				url: `zotero/${item.key}.zip`,
    480 				status: 204
    481 			});
    482 			setResponse({
    483 				method: "PUT",
    484 				url: `zotero/${item.key}.prop`,
    485 				status: 204
    486 			});
    487 			
    488 			var result = yield engine.start();
    489 			assertRequestCount(4);
    490 			
    491 			assert.isTrue(result.localChanges);
    492 			assert.isTrue(result.remoteChanges);
    493 			assert.isTrue(result.syncRequired);
    494 			assert.isFalse(result.fileSyncRequired);
    495 			
    496 			// Check local objects
    497 			assert.equal(item.attachmentSyncedModificationTime, mtime);
    498 			assert.equal(item.attachmentSyncedHash, hash);
    499 			assert.isFalse(item.synced);
    500 		})
    501 		
    502 		it("should skip upload that already exists on the server", function* () {
    503 			var engine = yield setup();
    504 			
    505 			var file = getTestDataDirectory();
    506 			file.append('test.png');
    507 			var item = yield Zotero.Attachments.importFromFile({ file });
    508 			item.synced = true;
    509 			yield item.saveTx();
    510 			var mtime = yield item.attachmentModificationTime;
    511 			var hash = yield item.attachmentHash;
    512 			var path = item.getFilePath();
    513 			var filename = 'test.png';
    514 			var size = (yield OS.File.stat(path)).size;
    515 			var contentType = 'image/png';
    516 			var fileContents = yield Zotero.File.getContentsAsync(path);
    517 			
    518 			setResponse({
    519 				method: "GET",
    520 				url: `zotero/${item.key}.prop`,
    521 				status: 200,
    522 				text: '<properties version="1">'
    523 					+ `<mtime>${mtime}</mtime>`
    524 					+ `<hash>${hash}</hash>`
    525 					+ '</properties>'
    526 			});
    527 			
    528 			var result = yield engine.start();
    529 			
    530 			assertRequestCount(1);
    531 			
    532 			assert.isFalse(result.localChanges);
    533 			assert.isFalse(result.remoteChanges);
    534 			assert.isFalse(result.syncRequired);
    535 			
    536 			// Check local object
    537 			assert.equal(item.attachmentSyncedModificationTime, mtime);
    538 			assert.equal(item.attachmentSyncedHash, hash);
    539 			assert.isFalse(item.synced);
    540 		})
    541 		
    542 		it("should mark item as in conflict if mod time and hash on storage server don't match synced values", function* () {
    543 			var engine = yield setup();
    544 			
    545 			var file = getTestDataDirectory();
    546 			file.append('test.png');
    547 			var item = yield Zotero.Attachments.importFromFile({ file });
    548 			item.synced = true;
    549 			yield item.saveTx();
    550 			var mtime = yield item.attachmentModificationTime;
    551 			var hash = yield item.attachmentHash;
    552 			var path = item.getFilePath();
    553 			var filename = 'test.png';
    554 			var size = (yield OS.File.stat(path)).size;
    555 			var contentType = 'image/png';
    556 			var fileContents = yield Zotero.File.getContentsAsync(path);
    557 			
    558 			var newModTime = mtime + 5000;
    559 			var newHash = "4f69f43d8ac8788190b13ff7f4a0a915";
    560 			
    561 			setResponse({
    562 				method: "GET",
    563 				url: `zotero/${item.key}.prop`,
    564 				status: 200,
    565 				text: '<properties version="1">'
    566 					+ `<mtime>${newModTime}</mtime>`
    567 					+ `<hash>${newHash}</hash>`
    568 					+ '</properties>'
    569 			});
    570 			
    571 			var result = yield engine.start();
    572 			
    573 			assertRequestCount(1);
    574 			
    575 			assert.isFalse(result.localChanges);
    576 			assert.isFalse(result.remoteChanges);
    577 			assert.isFalse(result.syncRequired);
    578 			assert.isTrue(result.fileSyncRequired);
    579 			
    580 			// Check local object
    581 			//
    582 			// Item should be marked as in conflict
    583 			assert.equal(item.attachmentSyncState, Zotero.Sync.Storage.Local.SYNC_STATE_IN_CONFLICT);
    584 			// Synced mod time should have been changed, because that's what's shown in the
    585 			// conflict dialog
    586 			assert.equal(item.attachmentSyncedModificationTime, newModTime);
    587 			assert.isTrue(item.synced);
    588 		})
    589 	});
    590 	
    591 	describe("Verify Server", function () {
    592 		it("should show an error for a connection error", function* () {
    593 			Zotero.HTTP.mock = null;
    594 			Zotero.Prefs.set("sync.storage.url", "127.0.0.1:9999");
    595 			
    596 			// Begin install procedure
    597 			var win = yield loadPrefPane('sync');
    598 			var button = win.document.getElementById('storage-verify');
    599 			
    600 			var spy = sinon.spy(win.Zotero_Preferences.Sync, "verifyStorageServer");
    601 			var promise1 = waitForDialog(function (dialog) {
    602 				assert.include(
    603 					dialog.document.documentElement.textContent,
    604 					Zotero.getString('sync.storage.error.serverCouldNotBeReached', '127.0.0.1')
    605 				);
    606 			});
    607 			button.click();
    608 			yield promise1;
    609 			
    610 			var promise2 = spy.returnValues[0];
    611 			spy.restore();
    612 			yield promise2;
    613 			
    614 			win.close();
    615 		});
    616 		
    617 		it("should show an error for a 403", function* () {
    618 			Zotero.HTTP.mock = null;
    619 			this.httpd.registerPathHandler(
    620 				`${davBasePath}zotero/`,
    621 				{
    622 					handle: function (request, response) {
    623 						response.setStatusLine(null, 403, null);
    624 					}
    625 				}
    626 			);
    627 			
    628 			// Use httpd.js instead of sinon so we get a real nsIURL with a channel
    629 			Zotero.Prefs.set("sync.storage.url", davHostPath);
    630 			
    631 			// Begin install procedure
    632 			var win = yield loadPrefPane('sync');
    633 			var button = win.document.getElementById('storage-verify');
    634 			
    635 			var spy = sinon.spy(win.Zotero_Preferences.Sync, "verifyStorageServer");
    636 			var promise1 = waitForDialog(function (dialog) {
    637 				assert.include(
    638 					dialog.document.documentElement.textContent,
    639 					Zotero.getString('sync.storage.error.webdav.permissionDenied', davBasePath + 'zotero/')
    640 				);
    641 			});
    642 			button.click();
    643 			yield promise1;
    644 			
    645 			var promise2 = spy.returnValues[0];
    646 			spy.restore();
    647 			yield promise2;
    648 			
    649 			win.close();
    650 		});
    651 	});
    652 	
    653 	describe("#purgeDeletedStorageFiles()", function () {
    654 		beforeEach(function () {
    655 			resetRequestCount();
    656 		})
    657 		
    658 		it("should delete files on storage server that were deleted locally", function* () {
    659 			var libraryID = Zotero.Libraries.userLibraryID;
    660 			
    661 			var file = getTestDataDirectory();
    662 			file.append('test.png');
    663 			var item = yield Zotero.Attachments.importFromFile({ file });
    664 			item.synced = true;
    665 			yield item.saveTx();
    666 			yield item.eraseTx();
    667 			
    668 			assert.lengthOf((yield Zotero.Sync.Storage.Local.getDeletedFiles(libraryID)), 1);
    669 			
    670 			setResponse({
    671 				method: "DELETE",
    672 				url: `zotero/${item.key}.prop`,
    673 				status: 204
    674 			});
    675 			setResponse({
    676 				method: "DELETE",
    677 				url: `zotero/${item.key}.zip`,
    678 				status: 204
    679 			});
    680 			var results = yield controller.purgeDeletedStorageFiles(libraryID);
    681 			assertRequestCount(2);
    682 			
    683 			assert.lengthOf(results.deleted, 2);
    684 			assert.sameMembers(results.deleted, [`${item.key}.prop`, `${item.key}.zip`]);
    685 			assert.lengthOf(results.missing, 0);
    686 			assert.lengthOf(results.error, 0);
    687 			
    688 			// Storage delete log should be empty
    689 			assert.lengthOf((yield Zotero.Sync.Storage.Local.getDeletedFiles(libraryID)), 0);
    690 		})
    691 	})
    692 	
    693 	describe("#purgeOrphanedStorageFiles()", function () {
    694 		beforeEach(function () {
    695 			resetRequestCount();
    696 			Zotero.Prefs.clear('lastWebDAVOrphanPurge');
    697 		})
    698 		
    699 		it("should delete orphaned files more than a week older than the last sync time", function* () {
    700 			var library = Zotero.Libraries.userLibrary;
    701 			library.updateLastSyncTime();
    702 			yield library.saveTx();
    703 			
    704 			// Create one item
    705 			var item1 = yield createDataObject('item');
    706 			var item1Key = item1.key;
    707 			// Add another item to sync queue
    708 			var item2Key = Zotero.DataObjectUtilities.generateKey();
    709 			yield Zotero.Sync.Data.Local.addObjectsToSyncQueue('item', library.id, [item2Key]);
    710 			
    711 			const daysBeforeSyncTime = 7;
    712 			
    713 			var beforeTime = new Date(Date.now() - (daysBeforeSyncTime * 86400 * 1000 + 1)).toUTCString();
    714 			var currentTime = new Date(Date.now() - 3600000).toUTCString();
    715 			
    716 			setResponse({
    717 				method: "PROPFIND",
    718 				url: `zotero/`,
    719 				status: 207,
    720 				headers: {
    721 					"Content-Type": 'text/xml; charset="utf-8"'
    722 				},
    723 				text: '<?xml version="1.0" encoding="utf-8"?>'
    724 					+ '<D:multistatus xmlns:D="DAV:" xmlns:ns0="DAV:">'
    725 						+ '<D:response xmlns:lp1="DAV:" xmlns:lp2="http://apache.org/dav/props/">'
    726 							+ `<D:href>${davBasePath}zotero/</D:href>`
    727 							+ '<D:propstat>'
    728 								+ '<D:prop>'
    729 								+ `<lp1:getlastmodified>${beforeTime}</lp1:getlastmodified>`
    730 								+ '</D:prop>'
    731 								+ '<D:status>HTTP/1.1 200 OK</D:status>'
    732 							+ '</D:propstat>'
    733 						+ '</D:response>'
    734 						+ '<D:response xmlns:lp1="DAV:" xmlns:lp2="http://apache.org/dav/props/">'
    735 							+ `<D:href>${davBasePath}zotero/lastsync.txt</D:href>`
    736 							+ '<D:propstat>'
    737 								+ '<D:prop>'
    738 								+ `<lp1:getlastmodified>${beforeTime}</lp1:getlastmodified>`
    739 								+ '</D:prop>'
    740 								+ '<D:status>HTTP/1.1 200 OK</D:status>'
    741 							+ '</D:propstat>'
    742 						+ '</D:response>'
    743 						+ '<D:response xmlns:lp1="DAV:" xmlns:lp2="http://apache.org/dav/props/">'
    744 							+ `<D:href>${davBasePath}zotero/lastsync</D:href>`
    745 							+ '<D:propstat>'
    746 								+ '<D:prop>'
    747 								+ `<lp1:getlastmodified>${beforeTime}</lp1:getlastmodified>`
    748 								+ '</D:prop>'
    749 								+ '<D:status>HTTP/1.1 200 OK</D:status>'
    750 							+ '</D:propstat>'
    751 						+ '</D:response>'
    752 						
    753 						+ '<D:response xmlns:lp1="DAV:" xmlns:lp2="http://apache.org/dav/props/">'
    754 							+ `<D:href>${davBasePath}zotero/AAAAAAAA.zip</D:href>`
    755 							+ '<D:propstat>'
    756 								+ '<D:prop>'
    757 								+ `<lp1:getlastmodified>${beforeTime}</lp1:getlastmodified>`
    758 								+ '</D:prop>'
    759 								+ '<D:status>HTTP/1.1 200 OK</D:status>'
    760 							+ '</D:propstat>'
    761 						+ '</D:response>'
    762 						+ '<D:response xmlns:lp1="DAV:" xmlns:lp2="http://apache.org/dav/props/">'
    763 							+ `<D:href>${davBasePath}zotero/AAAAAAAA.prop</D:href>`
    764 							+ '<D:propstat>'
    765 								+ '<D:prop>'
    766 								+ `<lp1:getlastmodified>${beforeTime}</lp1:getlastmodified>`
    767 								+ '</D:prop>'
    768 								+ '<D:status>HTTP/1.1 200 OK</D:status>'
    769 							+ '</D:propstat>'
    770 						+ '</D:response>'
    771 						
    772 						+ '<D:response xmlns:lp1="DAV:" xmlns:lp2="http://apache.org/dav/props/">'
    773 							+ `<D:href>${davBasePath}zotero/BBBBBBBB.zip</D:href>`
    774 							+ '<D:propstat>'
    775 								+ '<D:prop>'
    776 								+ `<lp1:getlastmodified>${currentTime}</lp1:getlastmodified>`
    777 								+ '</D:prop>'
    778 								+ '<D:status>HTTP/1.1 200 OK</D:status>'
    779 							+ '</D:propstat>'
    780 						+ '</D:response>'
    781 						+ '<D:response xmlns:lp1="DAV:" xmlns:lp2="http://apache.org/dav/props/">'
    782 							+ `<D:href>${davBasePath}zotero/BBBBBBBB.prop</D:href>`
    783 							+ '<D:propstat>'
    784 								+ '<D:prop>'
    785 								+ `<lp1:getlastmodified>${currentTime}</lp1:getlastmodified>`
    786 								+ '</D:prop>'
    787 								+ '<D:status>HTTP/1.1 200 OK</D:status>'
    788 							+ '</D:propstat>'
    789 						+ '</D:response>'
    790 						
    791 						// Item that exists
    792 						+ '<D:response xmlns:lp1="DAV:" xmlns:lp2="http://apache.org/dav/props/">'
    793 							+ `<D:href>${davBasePath}zotero/${item1Key}.zip</D:href>`
    794 							+ '<D:propstat>'
    795 								+ '<D:prop>'
    796 								+ `<lp1:getlastmodified>${beforeTime}</lp1:getlastmodified>`
    797 								+ '</D:prop>'
    798 								+ '<D:status>HTTP/1.1 200 OK</D:status>'
    799 							+ '</D:propstat>'
    800 						+ '</D:response>'
    801 						+ '<D:response xmlns:lp1="DAV:" xmlns:lp2="http://apache.org/dav/props/">'
    802 							+ `<D:href>${davBasePath}zotero/${item1Key}.prop</D:href>`
    803 							+ '<D:propstat>'
    804 								+ '<D:prop>'
    805 								+ `<lp1:getlastmodified>${beforeTime}</lp1:getlastmodified>`
    806 								+ '</D:prop>'
    807 								+ '<D:status>HTTP/1.1 200 OK</D:status>'
    808 							+ '</D:propstat>'
    809 						+ '</D:response>'
    810 						
    811 						// Item in sync queue
    812 						+ '<D:response xmlns:lp1="DAV:" xmlns:lp2="http://apache.org/dav/props/">'
    813 							+ `<D:href>${davBasePath}zotero/${item2Key}.zip</D:href>`
    814 							+ '<D:propstat>'
    815 								+ '<D:prop>'
    816 								+ `<lp1:getlastmodified>${beforeTime}</lp1:getlastmodified>`
    817 								+ '</D:prop>'
    818 								+ '<D:status>HTTP/1.1 200 OK</D:status>'
    819 							+ '</D:propstat>'
    820 						+ '</D:response>'
    821 						+ '<D:response xmlns:lp1="DAV:" xmlns:lp2="http://apache.org/dav/props/">'
    822 							+ `<D:href>${davBasePath}zotero/${item2Key}.prop</D:href>`
    823 							+ '<D:propstat>'
    824 								+ '<D:prop>'
    825 								+ `<lp1:getlastmodified>${beforeTime}</lp1:getlastmodified>`
    826 								+ '</D:prop>'
    827 								+ '<D:status>HTTP/1.1 200 OK</D:status>'
    828 							+ '</D:propstat>'
    829 						+ '</D:response>'
    830 					+ '</D:multistatus>'
    831 			});
    832 			setResponse({
    833 				method: "DELETE",
    834 				url: 'zotero/AAAAAAAA.prop',
    835 				status: 204
    836 			});
    837 			setResponse({
    838 				method: "DELETE",
    839 				url: 'zotero/AAAAAAAA.zip',
    840 				status: 204
    841 			});
    842 			setResponse({
    843 				method: "DELETE",
    844 				url: 'zotero/lastsync.txt',
    845 				status: 204
    846 			});
    847 			setResponse({
    848 				method: "DELETE",
    849 				url: 'zotero/lastsync',
    850 				status: 204
    851 			});
    852 			
    853 			var results = yield controller.purgeOrphanedStorageFiles();
    854 			assertRequestCount(5);
    855 			
    856 			assert.sameMembers(results.deleted, ['lastsync.txt', 'lastsync', 'AAAAAAAA.prop', 'AAAAAAAA.zip']);
    857 			assert.lengthOf(results.missing, 0);
    858 			assert.lengthOf(results.error, 0);
    859 		})
    860 		
    861 		it("shouldn't purge if purged recently", function* () {
    862 			Zotero.Prefs.set("lastWebDAVOrphanPurge", Math.round(new Date().getTime() / 1000) - 3600);
    863 			yield assert.eventually.equal(controller.purgeOrphanedStorageFiles(), false);
    864 			assertRequestCount(0);
    865 		});
    866 		
    867 		
    868 		it("should handle unnormalized Unicode characters", function* () {
    869 			var library = Zotero.Libraries.userLibrary;
    870 			library.updateLastSyncTime();
    871 			yield library.saveTx();
    872 			
    873 			const daysBeforeSyncTime = 7;
    874 			
    875 			var beforeTime = new Date(Date.now() - (daysBeforeSyncTime * 86400 * 1000 + 1)).toUTCString();
    876 			var currentTime = new Date(Date.now() - 3600000).toUTCString();
    877 			
    878 			var strC = '\u1E9B\u0323';
    879 			var encodedStrC = encodeURIComponent(strC);
    880 			var strD = '\u1E9B\u0323'.normalize('NFD');
    881 			var encodedStrD = encodeURIComponent(strD);
    882 			
    883 			setResponse({
    884 				method: "PROPFIND",
    885 				url: `${encodedStrC}/zotero/`,
    886 				status: 207,
    887 				headers: {
    888 					"Content-Type": 'text/xml; charset="utf-8"'
    889 				},
    890 				text: '<?xml version="1.0" encoding="utf-8"?>'
    891 					+ '<D:multistatus xmlns:D="DAV:" xmlns:ns0="DAV:">'
    892 						+ '<D:response xmlns:lp1="DAV:" xmlns:lp2="http://apache.org/dav/props/">'
    893 							+ `<D:href>${davBasePath}${encodedStrD}/zotero/</D:href>`
    894 							+ '<D:propstat>'
    895 								+ '<D:prop>'
    896 								+ `<lp1:getlastmodified>${beforeTime}</lp1:getlastmodified>`
    897 								+ '</D:prop>'
    898 								+ '<D:status>HTTP/1.1 200 OK</D:status>'
    899 							+ '</D:propstat>'
    900 						+ '</D:response>'
    901 						+ '<D:response xmlns:lp1="DAV:" xmlns:lp2="http://apache.org/dav/props/">'
    902 							+ `<D:href>${davBasePath}${encodedStrD}/zotero/lastsync</D:href>`
    903 							+ '<D:propstat>'
    904 								+ '<D:prop>'
    905 								+ `<lp1:getlastmodified>${beforeTime}</lp1:getlastmodified>`
    906 								+ '</D:prop>'
    907 								+ '<D:status>HTTP/1.1 200 OK</D:status>'
    908 							+ '</D:propstat>'
    909 						+ '</D:response>'
    910 						
    911 						+ '<D:response xmlns:lp1="DAV:" xmlns:lp2="http://apache.org/dav/props/">'
    912 							+ `<D:href>${davBasePath}${encodedStrD}/zotero/AAAAAAAA.zip</D:href>`
    913 							+ '<D:propstat>'
    914 								+ '<D:prop>'
    915 								+ `<lp1:getlastmodified>${beforeTime}</lp1:getlastmodified>`
    916 								+ '</D:prop>'
    917 								+ '<D:status>HTTP/1.1 200 OK</D:status>'
    918 							+ '</D:propstat>'
    919 						+ '</D:response>'
    920 						+ '<D:response xmlns:lp1="DAV:" xmlns:lp2="http://apache.org/dav/props/">'
    921 							+ `<D:href>${davBasePath}${encodedStrD}/zotero/AAAAAAAA.prop</D:href>`
    922 							+ '<D:propstat>'
    923 								+ '<D:prop>'
    924 								+ `<lp1:getlastmodified>${beforeTime}</lp1:getlastmodified>`
    925 								+ '</D:prop>'
    926 								+ '<D:status>HTTP/1.1 200 OK</D:status>'
    927 							+ '</D:propstat>'
    928 						+ '</D:response>'
    929 					+ '</D:multistatus>'
    930 			});
    931 			
    932 			Zotero.Prefs.set("sync.storage.url", davHostPath + strC + "/");
    933 			yield controller.purgeOrphanedStorageFiles();
    934 		})
    935 	})
    936 })