"use strict"; describe("Zotero.Sync.Storage.Mode.WebDAV", function () { // // Setup // Components.utils.import("resource://zotero-unit/httpd.js"); var apiKey = Zotero.Utilities.randomString(24); var apiPort = 16213; var apiURL = `http://localhost:${apiPort}/`; var davScheme = "http"; var davPort = 16214; var davBasePath = "/webdav/"; var davHostPath = `localhost:${davPort}${davBasePath}`; var davUsername = "user"; var davPassword = "password"; var davURL = `${davScheme}://${davUsername}:${davPassword}@${davHostPath}`; var win, controller, server, requestCount; var responses = {}; function setResponse(response) { setHTTPResponse(server, davURL, response, responses); } function resetRequestCount() { requestCount = server.requests.filter(r => r.responseHeaders["Fake-Server-Match"]).length; } function assertRequestCount(count) { assert.equal( server.requests.filter(r => r.responseHeaders["Fake-Server-Match"]).length - requestCount, count ); } function generateLastSyncID() { return "" + Zotero.Utilities.randomString(controller._lastSyncIDLength); } function parseQueryString(str) { var queryStringParams = str.split('&'); var params = {}; for (let param of queryStringParams) { let [ key, val ] = param.split('='); params[key] = decodeURIComponent(val); } return params; } function assertAPIKey(request) { assert.equal(request.requestHeaders["Zotero-API-Key"], apiKey); } beforeEach(function* () { yield resetDB({ thisArg: this, skipBundledFiles: true }); Zotero.HTTP.mock = sinon.FakeXMLHttpRequest; server = sinon.fakeServer.create(); server.autoRespond = true; this.httpd = new HttpServer(); this.httpd.start(davPort); yield Zotero.Users.setCurrentUserID(1); yield Zotero.Users.setCurrentUsername("testuser"); Zotero.Sync.Storage.Local.setModeForLibrary(Zotero.Libraries.userLibraryID, 'webdav'); controller = new Zotero.Sync.Storage.Mode.WebDAV; Zotero.Prefs.set("sync.storage.scheme", davScheme); Zotero.Prefs.set("sync.storage.url", davHostPath); Zotero.Prefs.set("sync.storage.username", davUsername); controller.password = davPassword; // Set download-on-sync by default Zotero.Sync.Storage.Local.downloadOnSync( Zotero.Libraries.userLibraryID, true ); }) var setup = Zotero.Promise.coroutine(function* (options = {}) { var engine = new Zotero.Sync.Storage.Engine({ libraryID: options.libraryID || Zotero.Libraries.userLibraryID, controller, stopOnError: true }); if (!controller.verified) { setResponse({ method: "OPTIONS", url: "zotero/", headers: { DAV: 1 }, status: 200 }) setResponse({ method: "PROPFIND", url: "zotero/", status: 207 }) setResponse({ method: "PUT", url: "zotero/zotero-test-file.prop", status: 201 }) setResponse({ method: "GET", url: "zotero/zotero-test-file.prop", status: 200 }) setResponse({ method: "DELETE", url: "zotero/zotero-test-file.prop", status: 200 }) yield controller.checkServer(); yield controller.cacheCredentials(); } resetRequestCount(); return engine; }) afterEach(function* () { var defer = new Zotero.Promise.defer(); this.httpd.stop(() => defer.resolve()); yield defer.promise; }) after(function* () { if (win) { win.close(); } }) // // Tests // describe("Syncing", function () { beforeEach(function* () { win = yield loadZoteroPane(); }) afterEach(function () { win.close(); }) it("should skip downloads if not marked as needed", function* () { var engine = yield setup(); var library = Zotero.Libraries.userLibrary; library.libraryVersion = 5; yield library.saveTx(); var result = yield engine.start(); assertRequestCount(0); assert.isFalse(result.localChanges); assert.isFalse(result.remoteChanges); assert.isFalse(result.syncRequired); assert.equal(library.storageVersion, library.libraryVersion); }) it("should ignore a remotely missing file", function* () { var engine = yield setup(); var library = Zotero.Libraries.userLibrary; library.libraryVersion = 5; yield library.saveTx(); library.storageDownloadNeeded = true; var item = new Zotero.Item("attachment"); item.attachmentLinkMode = 'imported_file'; item.attachmentPath = 'storage:test.txt'; item.attachmentSyncState = "to_download"; yield item.saveTx(); setResponse({ method: "GET", url: `zotero/${item.key}.prop`, status: 404 }); var result = yield engine.start(); assertRequestCount(1); assert.isFalse(result.localChanges); assert.isFalse(result.remoteChanges); assert.isFalse(result.syncRequired); assert.isFalse(library.storageDownloadNeeded); assert.equal(library.storageVersion, library.libraryVersion); }) it("should handle a remotely failing .prop file", function* () { var engine = yield setup(); var library = Zotero.Libraries.userLibrary; library.libraryVersion = 5; yield library.saveTx(); library.storageDownloadNeeded = true; var item = new Zotero.Item("attachment"); item.attachmentLinkMode = 'imported_file'; item.attachmentPath = 'storage:test.txt'; item.attachmentSyncState = "to_download"; yield item.saveTx(); setResponse({ method: "GET", url: `zotero/${item.key}.prop`, status: 500 }); // TODO: In stopOnError mode, the promise is rejected. // This should probably test with stopOnError mode turned off instead. var e = yield getPromiseError(engine.start()); assert.include( e.message, Zotero.getString('sync.storage.error.webdav.requestError', [500, "GET"]) ); assertRequestCount(1); assert.isTrue(library.storageDownloadNeeded); assert.equal(library.storageVersion, 0); }) it("should handle a remotely failing .zip file", function* () { var engine = yield setup(); var library = Zotero.Libraries.userLibrary; library.libraryVersion = 5; yield library.saveTx(); library.storageDownloadNeeded = true; var item = new Zotero.Item("attachment"); item.attachmentLinkMode = 'imported_file'; item.attachmentPath = 'storage:test.txt'; item.attachmentSyncState = "to_download"; yield item.saveTx(); setResponse({ method: "GET", url: `zotero/${item.key}.prop`, status: 200, text: '' + '1234567890' + '8286300a280f64a4b5cfaac547c21d32' + '' }); this.httpd.registerPathHandler( `${davBasePath}zotero/${item.key}.zip`, { handle: function (request, response) { response.setStatusLine(null, 500, null); } } ); // TODO: In stopOnError mode, the promise is rejected. // This should probably test with stopOnError mode turned off instead. var e = yield getPromiseError(engine.start()); assert.include( e.message, Zotero.getString('sync.storage.error.webdav.requestError', [500, "GET"]) ); assert.isTrue(library.storageDownloadNeeded); assert.equal(library.storageVersion, 0); }) it("should download a missing file", function* () { var engine = yield setup(); var library = Zotero.Libraries.userLibrary; library.libraryVersion = 5; yield library.saveTx(); library.storageDownloadNeeded = true; var fileName = "test.txt"; var item = new Zotero.Item("attachment"); item.attachmentLinkMode = 'imported_file'; item.attachmentPath = 'storage:' + fileName; // TODO: Test binary data var text = Zotero.Utilities.randomString(); item.attachmentSyncState = "to_download"; yield item.saveTx(); // Create ZIP file containing above text file var tmpPath = Zotero.getTempDirectory().path; var tmpID = "webdav_download_" + Zotero.Utilities.randomString(); var zipDirPath = OS.Path.join(tmpPath, tmpID); var zipPath = OS.Path.join(tmpPath, tmpID + ".zip"); yield OS.File.makeDir(zipDirPath); yield Zotero.File.putContentsAsync(OS.Path.join(zipDirPath, fileName), text); yield Zotero.File.zipDirectory(zipDirPath, zipPath); yield OS.File.removeDir(zipDirPath); yield Zotero.Promise.delay(1000); var zipContents = yield Zotero.File.getBinaryContentsAsync(zipPath); var mtime = "1441252524905"; var md5 = yield Zotero.Utilities.Internal.md5Async(zipPath); yield OS.File.remove(zipPath); setResponse({ method: "GET", url: `zotero/${item.key}.prop`, status: 200, text: '' + `${mtime}` + `${md5}` + '' }); this.httpd.registerPathHandler( `${davBasePath}zotero/${item.key}.zip`, { handle: function (request, response) { response.setStatusLine(null, 200, "OK"); response.write(zipContents); } } ); var result = yield engine.start(); assert.isTrue(result.localChanges); assert.isFalse(result.remoteChanges); assert.isFalse(result.syncRequired); var contents = yield Zotero.File.getContentsAsync(yield item.getFilePathAsync()); assert.equal(contents, text); assert.isFalse(library.storageDownloadNeeded); assert.equal(library.storageVersion, library.libraryVersion); }) it("should upload new files", function* () { var engine = yield setup(); var file = getTestDataDirectory(); file.append('test.png'); var item = yield Zotero.Attachments.importFromFile({ file }); item.synced = true; yield item.saveTx(); var mtime = yield item.attachmentModificationTime; var hash = yield item.attachmentHash; var path = item.getFilePath(); var filename = 'test.png'; var size = (yield OS.File.stat(path)).size; var contentType = 'image/png'; var fileContents = yield Zotero.File.getContentsAsync(path); var deferreds = []; setResponse({ method: "GET", url: `zotero/${item.key}.prop`, status: 404 }); // https://github.com/cjohansen/Sinon.JS/issues/607 let fixSinonBug = ";charset=utf-8"; server.respond(function (req) { if (req.method == "PUT" && req.url == `${davURL}zotero/${item.key}.zip`) { assert.equal(req.requestHeaders["Content-Type"], "application/zip" + fixSinonBug); let deferred = Zotero.Promise.defer(); deferreds.push(deferred); var reader = new FileReader(); reader.addEventListener("loadend", Zotero.Promise.coroutine(function* () { try { let tmpZipPath = OS.Path.join( Zotero.getTempDirectory().path, Zotero.Utilities.randomString() + '.zip' ); let file = yield OS.File.open(tmpZipPath, { create: true }); var contents = new Uint8Array(reader.result); yield file.write(contents); yield file.close(); // Make sure ZIP file contains the necessary entries var zr = Components.classes["@mozilla.org/libjar/zip-reader;1"] .createInstance(Components.interfaces.nsIZipReader); zr.open(Zotero.File.pathToFile(tmpZipPath)); zr.test(null); var entries = zr.findEntries('*'); var entryNames = []; while (entries.hasMore()) { entryNames.push(entries.getNext()); } assert.equal(entryNames.length, 1); assert.sameMembers(entryNames, [filename]); assert.equal(zr.getEntry(filename).realSize, size); yield OS.File.remove(tmpZipPath); deferred.resolve(); } catch (e) { deferred.reject(e); } })); reader.readAsArrayBuffer(req.requestBody); req.respond(201, { "Fake-Server-Match": 1 }, ""); } else if (req.method == "PUT" && req.url == `${davURL}zotero/${item.key}.prop`) { var parser = Components.classes["@mozilla.org/xmlextras/domparser;1"] .createInstance(Components.interfaces.nsIDOMParser); var doc = parser.parseFromString(req.requestBody, "text/xml"); assert.equal( doc.documentElement.getElementsByTagName('mtime')[0].textContent, mtime ); assert.equal( doc.documentElement.getElementsByTagName('hash')[0].textContent, hash ); req.respond(204, { "Fake-Server-Match": 1 }, ""); } }); var result = yield engine.start(); yield Zotero.Promise.all(deferreds.map(d => d.promise)); assertRequestCount(3); assert.isTrue(result.localChanges); assert.isTrue(result.remoteChanges); assert.isTrue(result.syncRequired); // Check local objects assert.equal(item.attachmentSyncedModificationTime, mtime); assert.equal(item.attachmentSyncedHash, hash); assert.isFalse(item.synced); }) it("should upload an updated file", function* () { var engine = yield setup(); var file = getTestDataDirectory(); file.append('test.txt'); var item = yield Zotero.Attachments.importFromFile({ file }); item.synced = true; yield item.saveTx(); var syncedModTime = Date.now() - 10000; var syncedHash = "3a2f092dd62178eb8bbfda42e07e64da"; item.attachmentSyncedModificationTime = syncedModTime; item.attachmentSyncedHash = syncedHash; yield item.saveTx({ skipAll: true }); var mtime = yield item.attachmentModificationTime; var hash = yield item.attachmentHash; setResponse({ method: "GET", url: `zotero/${item.key}.prop`, text: '' + `${syncedModTime}` + `${syncedHash}` + '' }); setResponse({ method: "DELETE", url: `zotero/${item.key}.prop`, status: 204 }); setResponse({ method: "PUT", url: `zotero/${item.key}.zip`, status: 204 }); setResponse({ method: "PUT", url: `zotero/${item.key}.prop`, status: 204 }); var result = yield engine.start(); assertRequestCount(4); assert.isTrue(result.localChanges); assert.isTrue(result.remoteChanges); assert.isTrue(result.syncRequired); assert.isFalse(result.fileSyncRequired); // Check local objects assert.equal(item.attachmentSyncedModificationTime, mtime); assert.equal(item.attachmentSyncedHash, hash); assert.isFalse(item.synced); }) it("should skip upload that already exists on the server", function* () { var engine = yield setup(); var file = getTestDataDirectory(); file.append('test.png'); var item = yield Zotero.Attachments.importFromFile({ file }); item.synced = true; yield item.saveTx(); var mtime = yield item.attachmentModificationTime; var hash = yield item.attachmentHash; var path = item.getFilePath(); var filename = 'test.png'; var size = (yield OS.File.stat(path)).size; var contentType = 'image/png'; var fileContents = yield Zotero.File.getContentsAsync(path); setResponse({ method: "GET", url: `zotero/${item.key}.prop`, status: 200, text: '' + `${mtime}` + `${hash}` + '' }); var result = yield engine.start(); assertRequestCount(1); assert.isFalse(result.localChanges); assert.isFalse(result.remoteChanges); assert.isFalse(result.syncRequired); // Check local object assert.equal(item.attachmentSyncedModificationTime, mtime); assert.equal(item.attachmentSyncedHash, hash); assert.isFalse(item.synced); }) it("should mark item as in conflict if mod time and hash on storage server don't match synced values", function* () { var engine = yield setup(); var file = getTestDataDirectory(); file.append('test.png'); var item = yield Zotero.Attachments.importFromFile({ file }); item.synced = true; yield item.saveTx(); var mtime = yield item.attachmentModificationTime; var hash = yield item.attachmentHash; var path = item.getFilePath(); var filename = 'test.png'; var size = (yield OS.File.stat(path)).size; var contentType = 'image/png'; var fileContents = yield Zotero.File.getContentsAsync(path); var newModTime = mtime + 5000; var newHash = "4f69f43d8ac8788190b13ff7f4a0a915"; setResponse({ method: "GET", url: `zotero/${item.key}.prop`, status: 200, text: '' + `${newModTime}` + `${newHash}` + '' }); var result = yield engine.start(); assertRequestCount(1); assert.isFalse(result.localChanges); assert.isFalse(result.remoteChanges); assert.isFalse(result.syncRequired); assert.isTrue(result.fileSyncRequired); // Check local object // // Item should be marked as in conflict assert.equal(item.attachmentSyncState, Zotero.Sync.Storage.Local.SYNC_STATE_IN_CONFLICT); // Synced mod time should have been changed, because that's what's shown in the // conflict dialog assert.equal(item.attachmentSyncedModificationTime, newModTime); assert.isTrue(item.synced); }) }) describe("#purgeDeletedStorageFiles()", function () { beforeEach(function () { resetRequestCount(); }) it("should delete files on storage server that were deleted locally", function* () { var libraryID = Zotero.Libraries.userLibraryID; var file = getTestDataDirectory(); file.append('test.png'); var item = yield Zotero.Attachments.importFromFile({ file }); item.synced = true; yield item.saveTx(); yield item.eraseTx(); assert.lengthOf((yield Zotero.Sync.Storage.Local.getDeletedFiles(libraryID)), 1); setResponse({ method: "DELETE", url: `zotero/${item.key}.prop`, status: 204 }); setResponse({ method: "DELETE", url: `zotero/${item.key}.zip`, status: 204 }); var results = yield controller.purgeDeletedStorageFiles(libraryID); assertRequestCount(2); assert.lengthOf(results.deleted, 2); assert.sameMembers(results.deleted, [`${item.key}.prop`, `${item.key}.zip`]); assert.lengthOf(results.missing, 0); assert.lengthOf(results.error, 0); // Storage delete log should be empty assert.lengthOf((yield Zotero.Sync.Storage.Local.getDeletedFiles(libraryID)), 0); }) }) describe("#purgeOrphanedStorageFiles()", function () { beforeEach(function () { resetRequestCount(); Zotero.Prefs.clear('lastWebDAVOrphanPurge'); }) it("should delete orphaned files more than a week older than the last sync time", function* () { var library = Zotero.Libraries.userLibrary; library.updateLastSyncTime(); yield library.saveTx(); const daysBeforeSyncTime = 7; var beforeTime = new Date(Date.now() - (daysBeforeSyncTime * 86400 * 1000 + 1)).toUTCString(); var currentTime = new Date(Date.now() - 3600000).toUTCString(); setResponse({ method: "PROPFIND", url: `zotero/`, status: 207, headers: { "Content-Type": 'text/xml; charset="utf-8"' }, text: '' + '' + '' + `${davBasePath}zotero/` + '' + '' + `${beforeTime}` + '' + 'HTTP/1.1 200 OK' + '' + '' + '' + `${davBasePath}zotero/lastsync.txt` + '' + '' + `${beforeTime}` + '' + 'HTTP/1.1 200 OK' + '' + '' + '' + `${davBasePath}zotero/lastsync` + '' + '' + `${beforeTime}` + '' + 'HTTP/1.1 200 OK' + '' + '' + '' + `${davBasePath}zotero/AAAAAAAA.zip` + '' + '' + `${beforeTime}` + '' + 'HTTP/1.1 200 OK' + '' + '' + '' + `${davBasePath}zotero/AAAAAAAA.prop` + '' + '' + `${beforeTime}` + '' + 'HTTP/1.1 200 OK' + '' + '' + '' + `${davBasePath}zotero/BBBBBBBB.zip` + '' + '' + `${currentTime}` + '' + 'HTTP/1.1 200 OK' + '' + '' + '' + `${davBasePath}zotero/BBBBBBBB.prop` + '' + '' + `${currentTime}` + '' + 'HTTP/1.1 200 OK' + '' + '' + '' }); setResponse({ method: "DELETE", url: 'zotero/AAAAAAAA.prop', status: 204 }); setResponse({ method: "DELETE", url: 'zotero/AAAAAAAA.zip', status: 204 }); setResponse({ method: "DELETE", url: 'zotero/lastsync.txt', status: 204 }); setResponse({ method: "DELETE", url: 'zotero/lastsync', status: 204 }); yield controller.purgeOrphanedStorageFiles(); assertRequestCount(5); }) it("shouldn't purge if purged recently", function* () { Zotero.Prefs.set("lastWebDAVOrphanPurge", Math.round(new Date().getTime() / 1000) - 3600); yield assert.eventually.equal(controller.purgeOrphanedStorageFiles(), false); assertRequestCount(0); }) }) })