From 5fee2bf4ca1b69268f2ffb4b24f4206fc00a86c3 Mon Sep 17 00:00:00 2001 From: Dan Stillman Date: Tue, 19 Jul 2016 18:58:48 -0400 Subject: [PATCH] Prompt to reset library data/files on loss of write access On reset, items are overwritten with pristine versions if available and deleted otherwise, and then the library is marked for a full sync. Unsynced/changed files are deleted and marked for download. Closes #1002 Todo: - Handle API key access change (#953, in part) - Handle 403 from data/file upload for existing users (#1041) --- chrome/content/zotero/xpcom/data/group.js | 20 +- chrome/content/zotero/xpcom/data/groups.js | 27 +++ chrome/content/zotero/xpcom/data/item.js | 2 +- chrome/content/zotero/xpcom/sync/syncLocal.js | 209 ++++++++++++++++- .../content/zotero/xpcom/sync/syncRunner.js | 28 ++- chrome/locale/en-US/zotero/zotero.properties | 9 +- test/tests/syncLocalTest.js | 210 ++++++++++++++++++ test/tests/syncRunnerTest.js | 82 +++++++ 8 files changed, 552 insertions(+), 35 deletions(-) diff --git a/chrome/content/zotero/xpcom/data/group.js b/chrome/content/zotero/xpcom/data/group.js index 5755502619..99f92d5f5d 100644 --- a/chrome/content/zotero/xpcom/data/group.js +++ b/chrome/content/zotero/xpcom/data/group.js @@ -23,6 +23,8 @@ ***** END LICENSE BLOCK ***** */ +"use strict"; + Zotero.Group = function (params = {}) { params.libraryType = 'group'; Zotero.Group._super.call(this, params); @@ -240,23 +242,7 @@ Zotero.Group.prototype.fromJSON = function (json, userID) { var editable = false; var filesEditable = false; if (userID) { - // If user is owner or admin, make library editable, and make files editable unless they're - // disabled altogether - if (json.owner == userID || (json.admins && json.admins.indexOf(userID) != -1)) { - editable = true; - if (json.fileEditing != 'none') { - filesEditable = true; - } - } - // If user is member, make library and files editable if they're editable by all members - else if (json.members && json.members.indexOf(userID) != -1) { - if (json.libraryEditing == 'members') { - editable = true; - if (json.fileEditing == 'members') { - filesEditable = true; - } - } - } + ({ editable, filesEditable } = Zotero.Groups.getPermissionsFromJSON(json, userID)); } this.editable = editable; this.filesEditable = filesEditable; diff --git a/chrome/content/zotero/xpcom/data/groups.js b/chrome/content/zotero/xpcom/data/groups.js index 7d0e6a3d86..503043e0d3 100644 --- a/chrome/content/zotero/xpcom/data/groups.js +++ b/chrome/content/zotero/xpcom/data/groups.js @@ -116,4 +116,31 @@ Zotero.Groups = new function () { return this._cache.libraryIDByGroupID[groupID] || false; } + + + this.getPermissionsFromJSON = function (json, userID) { + if (!json.owner) throw new Error("Invalid JSON provided for group data"); + if (!userID) throw new Error("userID not provided"); + + var editable = false; + var filesEditable = false; + // If user is owner or admin, make library editable, and make files editable unless they're + // disabled altogether + if (json.owner == userID || (json.admins && json.admins.indexOf(userID) != -1)) { + editable = true; + if (json.fileEditing != 'none') { + filesEditable = true; + } + } + // If user is member, make library and files editable if they're editable by all members + else if (json.members && json.members.indexOf(userID) != -1) { + if (json.libraryEditing == 'members') { + editable = true; + if (json.fileEditing == 'members') { + filesEditable = true; + } + } + } + return { editable, filesEditable }; + }; } diff --git a/chrome/content/zotero/xpcom/data/item.js b/chrome/content/zotero/xpcom/data/item.js index f1cd7bc7dd..37fadfde7f 100644 --- a/chrome/content/zotero/xpcom/data/item.js +++ b/chrome/content/zotero/xpcom/data/item.js @@ -2399,7 +2399,7 @@ Zotero.Item.prototype.getFilename = function () { /** - * Asynchronous cached check for file existence, used for items view + * Asynchronous check for file existence */ Zotero.Item.prototype.fileExists = Zotero.Promise.coroutine(function* () { if (!this.isAttachment()) { diff --git a/chrome/content/zotero/xpcom/sync/syncLocal.js b/chrome/content/zotero/xpcom/sync/syncLocal.js index f71716e9c9..a6dd980c44 100644 --- a/chrome/content/zotero/xpcom/sync/syncLocal.js +++ b/chrome/content/zotero/xpcom/sync/syncLocal.js @@ -155,6 +155,207 @@ Zotero.Sync.Data.Local = { }), + /** + * @return {Promise} - True if library updated, false to cancel + */ + checkLibraryForAccess: Zotero.Promise.coroutine(function* (win, libraryID, editable, filesEditable) { + var library = Zotero.Libraries.get(libraryID); + + // If library is going from editable to non-editable and there's unsynced local data, prompt + if (library.editable && !editable + && ((yield this._libraryHasUnsyncedData(libraryID)) + || (yield this._libraryHasUnsyncedFiles(libraryID)))) { + let index = this._showWriteAccessLostPrompt(win, library); + + // Reset library + if (index == 0) { + yield this._resetUnsyncedLibraryData(libraryID); + return true; + } + + // Skip library + return false; + } + + if (library.filesEditable && !filesEditable && (yield this._libraryHasUnsyncedFiles(libraryID))) { + let index = this._showFileWriteAccessLostPrompt(win, library); + + // Reset library files + if (index == 0) { + yield this._resetUnsyncedLibraryFiles(libraryID); + return true; + } + + // Skip library + return false; + } + + return true; + }), + + + _libraryHasUnsyncedData: Zotero.Promise.coroutine(function* (libraryID) { + let settings = yield Zotero.SyncedSettings.getUnsynced(libraryID); + if (Object.keys(settings).length) { + return true; + } + + for (let objectType of Zotero.DataObjectUtilities.getTypesForLibrary(libraryID)) { + let ids = yield Zotero.Sync.Data.Local.getUnsynced(objectType, libraryID); + if (ids.length) { + return true; + } + + let keys = yield Zotero.Sync.Data.Local.getDeleted(objectType, libraryID); + if (keys.length) { + return true; + } + } + + return false; + }), + + + _libraryHasUnsyncedFiles: Zotero.Promise.coroutine(function* (libraryID) { + yield Zotero.Sync.Storage.Local.checkForUpdatedFiles(libraryID); + return !!(yield Zotero.Sync.Storage.Local.getFilesToUpload(libraryID)); + }), + + + _showWriteAccessLostPrompt: function (win, library) { + var libraryType = library.libraryType; + switch (libraryType) { + case 'group': + var msg = Zotero.getString('sync.error.groupWriteAccessLost', + [library.name, ZOTERO_CONFIG.DOMAIN_NAME]) + + "\n\n" + + Zotero.getString('sync.error.groupCopyChangedItems') + var button1Text = Zotero.getString('sync.resetGroupAndSync'); + var button2Text = Zotero.getString('sync.skipGroup'); + break; + + default: + throw new Error("Unsupported library type " + libraryType); + } + + var ps = Components.classes["@mozilla.org/embedcomp/prompt-service;1"] + .getService(Components.interfaces.nsIPromptService); + var buttonFlags = (ps.BUTTON_POS_0) * (ps.BUTTON_TITLE_IS_STRING) + + (ps.BUTTON_POS_1) * (ps.BUTTON_TITLE_IS_STRING) + + ps.BUTTON_DELAY_ENABLE; + + return ps.confirmEx( + win, + Zotero.getString('general.permissionDenied'), + msg, + buttonFlags, + button1Text, + button2Text, + null, + null, {} + ); + }, + + + _showFileWriteAccessLostPrompt: function (win, library) { + var libraryType = library.libraryType; + switch (libraryType) { + case 'group': + var msg = Zotero.getString('sync.error.groupFileWriteAccessLost', + [library.name, ZOTERO_CONFIG.DOMAIN_NAME]) + + "\n\n" + + Zotero.getString('sync.error.groupCopyChangedFiles') + var button1Text = Zotero.getString('sync.resetGroupFilesAndSync'); + var button2Text = Zotero.getString('sync.skipGroup'); + break; + + default: + throw new Error("Unsupported library type " + libraryType); + } + + var ps = Components.classes["@mozilla.org/embedcomp/prompt-service;1"] + .getService(Components.interfaces.nsIPromptService); + var buttonFlags = (ps.BUTTON_POS_0) * (ps.BUTTON_TITLE_IS_STRING) + + (ps.BUTTON_POS_1) * (ps.BUTTON_TITLE_IS_STRING) + + ps.BUTTON_DELAY_ENABLE; + + return ps.confirmEx( + win, + Zotero.getString('general.permissionDenied'), + msg, + buttonFlags, + button1Text, + button2Text, + null, + null, {} + ); + }, + + + _resetUnsyncedLibraryData: Zotero.Promise.coroutine(function* (libraryID) { + let settings = yield Zotero.SyncedSettings.getUnsynced(libraryID); + if (Object.keys(settings).length) { + yield Zotero.Promise.each(Object.keys(settings), function (key) { + return Zotero.SyncedSettings.clear(libraryID, key, { skipDeleteLog: true }); + }); + } + + for (let objectType of Zotero.DataObjectUtilities.getTypesForLibrary(libraryID)) { + let objectTypePlural = Zotero.DataObjectUtilities.getObjectTypePlural(objectType); + let objectsClass = Zotero.DataObjectUtilities.getObjectsClassForObjectType(objectType); + + // New/modified objects + let ids = yield Zotero.Sync.Data.Local.getUnsynced(objectType, libraryID); + let keys = ids.map(id => objectsClass.getLibraryAndKeyFromID(id).key); + let cacheVersions = yield this.getLatestCacheObjectVersions(objectType, libraryID, keys); + let toDelete = []; + for (let key of keys) { + let obj = objectsClass.getByLibraryAndKey(libraryID, key); + + // If object is in cache, overwrite with pristine data + if (cacheVersions[key]) { + let json = yield this.getCacheObject(objectType, libraryID, key, cacheVersions[key]); + yield Zotero.DB.executeTransaction(function* () { + yield this._saveObjectFromJSON(obj, json, {}); + }.bind(this)); + } + // Otherwise, erase + else { + toDelete.push(objectsClass.getIDFromLibraryAndKey(libraryID, key)); + } + } + if (toDelete.length) { + yield objectsClass.erase(toDelete, { skipDeleteLog: true }); + } + + // Deleted objects + keys = yield Zotero.Sync.Data.Local.getDeleted(objectType, libraryID); + yield this.removeObjectsFromDeleteLog(objectType, libraryID, keys); + } + + // Mark library for full sync + var library = Zotero.Libraries.get(libraryID); + library.libraryVersion = -1; + yield library.saveTx(); + + yield this._resetUnsyncedLibraryFiles(libraryID); + }), + + + /** + * Delete unsynced files from library + * + * _libraryHasUnsyncedFiles(), which checks for updated files, must be called first. + */ + _resetUnsyncedLibraryFiles: Zotero.Promise.coroutine(function* (libraryID) { + var itemIDs = yield Zotero.Sync.Storage.Local.getFilesToUpload(libraryID); + for (let itemID of itemIDs) { + let item = Zotero.Items.get(itemID); + yield item.deleteAttachmentFile(); + } + }), + + getSkippedLibraries: function () { return this._getSkippedLibrariesByPrefix("L"); }, @@ -1117,11 +1318,11 @@ Zotero.Sync.Data.Local = { }), _saveObjectFromJSON: Zotero.Promise.coroutine(function* (obj, json, options) { + var results = {}; try { + results.key = json.key; json = this._checkCacheJSON(json); - var results = { - key: json.key - }; + if (!options.skipData) { obj.fromJSON(json.data); } @@ -1385,6 +1586,8 @@ Zotero.Sync.Data.Local = { * @return {Promise} */ removeObjectsFromDeleteLog: function (objectType, libraryID, keys) { + if (!keys.length) Zotero.Promise.resolve(); + var syncObjectTypeID = Zotero.Sync.Data.Utilities.getSyncObjectTypeID(objectType); var sql = "DELETE FROM syncDeleteLog WHERE libraryID=? AND syncObjectTypeID=? AND key IN ("; return Zotero.DB.executeTransaction(function* () { diff --git a/chrome/content/zotero/xpcom/sync/syncRunner.js b/chrome/content/zotero/xpcom/sync/syncRunner.js index 6a8eb90a9b..6ad0eed57e 100644 --- a/chrome/content/zotero/xpcom/sync/syncRunner.js +++ b/chrome/content/zotero/xpcom/sync/syncRunner.js @@ -293,15 +293,6 @@ Zotero.Sync.Runner_Module = function (options = {}) { */ this.checkLibraries = Zotero.Promise.coroutine(function* (client, options, keyInfo, libraries = []) { var access = keyInfo.access; - -/* var libraries = [ - Zotero.Libraries.userLibraryID, - Zotero.Libraries.publicationsLibraryID, - // Groups sorted by name - ...(Zotero.Groups.getAll().map(x => x.libraryID)) - ]; -*/ - var syncAllLibraries = !libraries || !libraries.length; // TODO: Ability to remove or disable editing of user library? @@ -309,7 +300,7 @@ Zotero.Sync.Runner_Module = function (options = {}) { if (syncAllLibraries) { if (access.user && access.user.library) { libraries = [Zotero.Libraries.userLibraryID, Zotero.Libraries.publicationsLibraryID]; - // Remove skipped libraries + // If syncing all libraries, remove skipped libraries libraries = Zotero.Utilities.arrayDiff( libraries, Zotero.Sync.Data.Local.getSkippedLibraries() ); @@ -472,7 +463,22 @@ Zotero.Sync.Runner_Module = function (options = {}) { throw new Error("Group " + groupID + " not found"); } let group = Zotero.Groups.get(groupID); - if (!group) { + if (group) { + // Check if the user's permissions for the group have changed, and prompt to reset + // data if so + let { editable, filesEditable } = Zotero.Groups.getPermissionsFromJSON( + info.data, keyInfo.userID + ); + let keepGoing = yield Zotero.Sync.Data.Local.checkLibraryForAccess( + null, group.libraryID, editable, filesEditable + ); + // User chose to skip library + if (!keepGoing) { + Zotero.debug("Skipping sync of group " + group.id); + continue; + } + } + else { group = new Zotero.Group; group.id = groupID; } diff --git a/chrome/locale/en-US/zotero/zotero.properties b/chrome/locale/en-US/zotero/zotero.properties index a9fcacf583..5d44d22e32 100644 --- a/chrome/locale/en-US/zotero/zotero.properties +++ b/chrome/locale/en-US/zotero/zotero.properties @@ -825,6 +825,8 @@ sync.syncWith = Sync with %S sync.cancel = Cancel Sync sync.openSyncPreferences = Open Sync Preferences sync.resetGroupAndSync = Reset Group and Sync +sync.resetGroupFilesAndSync = Reset Group Files and Sync +sync.skipGroup = Skip Group sync.removeGroupsAndSync = Remove Groups and Sync sync.error.usernameNotSet = Username not set @@ -840,9 +842,10 @@ sync.error.loginManagerCorrupted1 = Zotero cannot access your login information, sync.error.loginManagerCorrupted2 = Close %1$S, remove cert8.db, key3.db, and logins.json from your %2$S profile directory, and re-enter your Zotero login information in the Sync pane of the Zotero preferences. sync.error.syncInProgress = A sync operation is already in progress. sync.error.syncInProgress.wait = Wait for the previous sync to complete or restart %S. -sync.error.writeAccessLost = You no longer have write access to the Zotero group '%S', and items you've added or edited cannot be synced to the server. -sync.error.groupWillBeReset = If you continue, your copy of the group will be reset to its state on the server, and local modifications to items and files will be lost. -sync.error.copyChangedItems = If you would like a chance to copy your changes elsewhere or to request write access from a group administrator, cancel the sync now. +sync.error.groupWriteAccessLost = You no longer have write access to the group ‘%1$S’, and changes you’ve made locally cannot be uploaded. If you continue, your copy of the group will be reset to its state on %2$S, and local changes to items and files will be lost. +sync.error.groupFileWriteAccessLost = You no longer have file editing access for the group ‘%1$S’, and files you’ve changed locally cannot be uploaded. If you continue, all group files will be reset to their state on %2$S. +sync.error.groupCopyChangedItems = If you would like a chance to copy your changes elsewhere or to request write access from a group administrator, you can skip syncing of the group now. +sync.error.groupCopyChangedFiles = If you would like a chance to copy modified files elsewhere or to request file editing access from a group administrator, you can skip syncing of the group now. sync.error.manualInterventionRequired = Conflicts have suspended automatic syncing. sync.error.clickSyncIcon = Click the sync icon to resolve them. sync.error.invalidClock = The system clock is set to an invalid time. You will need to correct this to sync with the Zotero server. diff --git a/test/tests/syncLocalTest.js b/test/tests/syncLocalTest.js index a1cd182dd8..35ba6198e3 100644 --- a/test/tests/syncLocalTest.js +++ b/test/tests/syncLocalTest.js @@ -96,6 +96,216 @@ describe("Zotero.Sync.Data.Local", function() { }); + describe("#checkLibraryForAccess()", function () { + // + // editable + // + it("should prompt if library is changing from editable to non-editable and reset library on accept", function* () { + var group = yield createGroup(); + var libraryID = group.libraryID; + var promise = waitForDialog(function (dialog) { + var text = dialog.document.documentElement.textContent; + assert.include(text, group.name); + }); + + var mock = sinon.mock(Zotero.Sync.Data.Local); + mock.expects("_resetUnsyncedLibraryData").once().returns(Zotero.Promise.resolve()); + mock.expects("_resetUnsyncedLibraryFiles").never(); + + assert.isTrue( + yield Zotero.Sync.Data.Local.checkLibraryForAccess(null, libraryID, false, false) + ); + yield promise; + + mock.verify(); + }); + + it("should prompt if library is changing from editable to non-editable but not reset library on cancel", function* () { + var group = yield createGroup(); + var libraryID = group.libraryID; + var promise = waitForDialog(function (dialog) { + var text = dialog.document.documentElement.textContent; + assert.include(text, group.name); + }, "cancel"); + + var mock = sinon.mock(Zotero.Sync.Data.Local); + mock.expects("_resetUnsyncedLibraryData").never(); + mock.expects("_resetUnsyncedLibraryFiles").never(); + + assert.isFalse( + yield Zotero.Sync.Data.Local.checkLibraryForAccess(null, libraryID, false, false) + ); + yield promise; + + mock.verify(); + }); + + it("should not prompt if library is changing from editable to non-editable", function* () { + var group = yield createGroup({ editable: false, filesEditable: false }); + var libraryID = group.libraryID; + yield Zotero.Sync.Data.Local.checkLibraryForAccess(null, libraryID, true, true); + }); + + // + // filesEditable + // + it("should prompt if library is changing from filesEditable to non-filesEditable and reset library files on accept", function* () { + var group = yield createGroup(); + var libraryID = group.libraryID; + var promise = waitForDialog(function (dialog) { + var text = dialog.document.documentElement.textContent; + assert.include(text, group.name); + }); + + var mock = sinon.mock(Zotero.Sync.Data.Local); + mock.expects("_resetUnsyncedLibraryData").never(); + mock.expects("_resetUnsyncedLibraryFiles").once().returns(Zotero.Promise.resolve()); + + assert.isTrue( + yield Zotero.Sync.Data.Local.checkLibraryForAccess(null, libraryID, true, false) + ); + yield promise; + + mock.verify(); + }); + + it("should prompt if library is changing from filesEditable to non-filesEditable but not reset library files on cancel", function* () { + var group = yield createGroup(); + var libraryID = group.libraryID; + var promise = waitForDialog(function (dialog) { + var text = dialog.document.documentElement.textContent; + assert.include(text, group.name); + }, "cancel"); + + var mock = sinon.mock(Zotero.Sync.Data.Local); + mock.expects("_resetUnsyncedLibraryData").never(); + mock.expects("_resetUnsyncedLibraryFiles").never(); + + assert.isFalse( + yield Zotero.Sync.Data.Local.checkLibraryForAccess(null, libraryID, true, false) + ); + yield promise; + + mock.verify(); + }); + }); + + + describe("#_libraryHasUnsyncedData()", function () { + it("should return true for unsynced setting", function* () { + var group = yield createGroup(); + var libraryID = group.libraryID; + yield Zotero.SyncedSettings.set(libraryID, "testSetting", { foo: "bar" }); + assert.isTrue(yield Zotero.Sync.Data.Local._libraryHasUnsyncedData(libraryID)); + }); + + it("should return true for unsynced item", function* () { + var group = yield createGroup(); + var libraryID = group.libraryID; + yield createDataObject('item', { libraryID }); + assert.isTrue(yield Zotero.Sync.Data.Local._libraryHasUnsyncedData(libraryID)); + }); + + it("should return false if no changes", function* () { + var group = yield createGroup(); + var libraryID = group.libraryID; + assert.isFalse(yield Zotero.Sync.Data.Local._libraryHasUnsyncedData(libraryID)); + }); + }); + + + describe("#_resetUnsyncedLibraryData()", function () { + it("should revert group and mark for full sync", function* () { + var group = yield createGroup({ + version: 1, + libraryVersion: 2 + }); + var libraryID = group.libraryID; + + // New setting + yield Zotero.SyncedSettings.set(libraryID, "testSetting", { foo: "bar" }); + + // Changed collection + var changedCollection = yield createDataObject('collection', { libraryID, version: 1 }); + var originalCollectionName = changedCollection.name; + yield Zotero.Sync.Data.Local.saveCacheObject( + 'collection', libraryID, changedCollection.toJSON() + ); + yield modifyDataObject(changedCollection); + + // Unchanged item + var unchangedItem = yield createDataObject('item', { libraryID, version: 1, synced: true }); + yield Zotero.Sync.Data.Local.saveCacheObject( + 'item', libraryID, unchangedItem.toJSON() + ); + + // Changed item + var changedItem = yield createDataObject('item', { libraryID, version: 1 }); + var originalChangedItemTitle = changedItem.getField('title'); + yield Zotero.Sync.Data.Local.saveCacheObject('item', libraryID, changedItem.toJSON()); + yield modifyDataObject(changedItem); + + // New item + var newItem = yield createDataObject('item', { libraryID, version: 1 }); + var newItemKey = newItem.key; + + // Delete item + var deletedItem = yield createDataObject('item', { libraryID }); + var deletedItemKey = deletedItem.key; + yield deletedItem.eraseTx(); + + yield Zotero.Sync.Data.Local._resetUnsyncedLibraryData(libraryID); + + assert.isNull(Zotero.SyncedSettings.get(group.libraryID, "testSetting")); + + assert.equal(changedCollection.name, originalCollectionName); + assert.isTrue(changedCollection.synced); + + assert.isTrue(unchangedItem.synced); + + assert.equal(changedItem.getField('title'), originalChangedItemTitle); + assert.isTrue(changedItem.synced); + + assert.isFalse(Zotero.Items.get(newItemKey)); + + assert.isFalse(yield Zotero.Sync.Data.Local.getDateDeleted('item', libraryID, deletedItemKey)); + + assert.equal(group.libraryVersion, -1); + }); + + + describe("#_resetUnsyncedLibraryFiles", function () { + it("should delete unsynced files", function* () { + var group = yield createGroup({ + version: 1, + libraryVersion: 2 + }); + var libraryID = group.libraryID; + + var attachment1 = yield importFileAttachment('test.png', { libraryID }); + attachment1.attachmentSyncState = "in_sync"; + attachment1.attachmentSyncedModificationTime = 1234567890000; + attachment1.attachmentSyncedHash = "8caf2ee22919d6725eb0648b98ef6bad"; + var attachment2 = yield importFileAttachment('test.pdf', { libraryID }); + + // Has to be called before _resetUnsyncedLibraryFiles() + assert.isTrue(yield Zotero.Sync.Data.Local._libraryHasUnsyncedFiles(libraryID)); + + yield Zotero.Sync.Data.Local._resetUnsyncedLibraryFiles(libraryID); + + assert.isFalse(yield attachment1.fileExists()); + assert.isFalse(yield attachment2.fileExists()); + assert.equal( + attachment1.attachmentSyncState, Zotero.Sync.Storage.Local.SYNC_STATE_TO_DOWNLOAD + ); + assert.equal( + attachment2.attachmentSyncState, Zotero.Sync.Storage.Local.SYNC_STATE_TO_DOWNLOAD + ); + }); + }); + }); + + describe("#getLatestCacheObjectVersions", function () { before(function* () { yield resetDB({ diff --git a/test/tests/syncRunnerTest.js b/test/tests/syncRunnerTest.js index 2c839978d5..6158feb8e5 100644 --- a/test/tests/syncRunnerTest.js +++ b/test/tests/syncRunnerTest.js @@ -309,9 +309,16 @@ describe("Zotero.Sync.Runner", function () { setResponse('userGroups.groupVersions'); setResponse('groups.ownerGroup'); setResponse('groups.memberGroup'); + // Simulate acceptance of library reset for group 2 editable change + var stub = sinon.stub(Zotero.Sync.Data.Local, "checkLibraryForAccess") + .returns(Zotero.Promise.resolve(true)); + var libraries = yield runner.checkLibraries( runner.getAPIClient({ apiKey }), false, responses.keyInfo.fullAccess.json ); + + assert.ok(stub.calledTwice); + stub.restore(); assert.lengthOf(libraries, 4); assert.sameMembers( libraries, @@ -350,12 +357,19 @@ describe("Zotero.Sync.Runner", function () { setResponse('userGroups.groupVersions'); setResponse('groups.ownerGroup'); setResponse('groups.memberGroup'); + // Simulate acceptance of library reset for group 2 editable change + var stub = sinon.stub(Zotero.Sync.Data.Local, "checkLibraryForAccess") + .returns(Zotero.Promise.resolve(true)); + var libraries = yield runner.checkLibraries( runner.getAPIClient({ apiKey }), false, responses.keyInfo.fullAccess.json, [group1.libraryID, group2.libraryID] ); + + assert.ok(stub.calledTwice); + stub.restore(); assert.lengthOf(libraries, 2); assert.sameMembers(libraries, [group1.libraryID, group2.libraryID]); @@ -443,6 +457,74 @@ describe("Zotero.Sync.Runner", function () { assert.lengthOf(libraries, 0); assert.isTrue(Zotero.Groups.exists(groupData.json.id)); }) + + it("should prompt to revert local changes on loss of library write access", function* () { + var group = yield createGroup({ + version: 1, + libraryVersion: 2 + }); + var libraryID = group.libraryID; + + setResponse({ + method: "GET", + url: "users/1/groups?format=versions", + status: 200, + headers: { + "Last-Modified-Version": 3 + }, + json: { + [group.id]: 3 + } + }); + setResponse({ + method: "GET", + url: "groups/" + group.id, + status: 200, + headers: { + "Last-Modified-Version": 3 + }, + json: { + id: group.id, + version: 2, + data: { + // Make group read-only + id: group.id, + version: 2, + name: group.name, + description: group.description, + owner: 2, + type: "Private", + libraryEditing: "admins", + libraryReading: "all", + fileEditing: "admins", + admins: [], + members: [1] + } + } + }); + + // First, test cancelling + var stub = sinon.stub(Zotero.Sync.Data.Local, "checkLibraryForAccess") + .returns(Zotero.Promise.resolve(false)); + var libraries = yield runner.checkLibraries( + runner.getAPIClient({ apiKey }), false, responses.keyInfo.fullAccess.json + ); + assert.notInclude(libraries, group.libraryID); + assert.isTrue(stub.calledOnce); + assert.isTrue(group.editable); + stub.reset(); + + // Next, reset + stub.returns(Zotero.Promise.resolve(true)); + libraries = yield runner.checkLibraries( + runner.getAPIClient({ apiKey }), false, responses.keyInfo.fullAccess.json + ); + assert.include(libraries, group.libraryID); + assert.isTrue(stub.calledOnce); + assert.isFalse(group.editable); + + stub.reset(); + }); }) describe("#sync()", function () {