mirror of
https://github.com/laurent22/joplin.git
synced 2024-12-24 10:27:10 +02:00
e11e57f1d8
The implementation uses / symbol as a nesting separator. I.e. tag/subtag is a nested tag, where tag is the parent tag and subtag is its child. Creating a tag named tag/subtag/subsubtag creates three tags, one for each level. The tags are associated using parent_id field. In the app, viewing notes with a tag will also show all notes that are associated with any of the tag's descendant tags (same for the note count). Deleting a tag will also delete all its descendant tags. In the desktop app the tags are shown nested just like the notebooks.
1805 lines
62 KiB
JavaScript
1805 lines
62 KiB
JavaScript
/* eslint-disable no-unused-vars */
|
|
|
|
require('app-module-path').addPath(__dirname);
|
|
|
|
const { time } = require('lib/time-utils.js');
|
|
const { setupDatabase, allSyncTargetItemsEncrypted, tempFilePath, resourceFetcher, kvStore, revisionService, setupDatabaseAndSynchronizer, db, synchronizer, fileApi, sleep, clearDatabase, switchClient, syncTargetId, encryptionService, loadEncryptionMasterKey, fileContentEqual, decryptionWorker, checkThrowAsync, asyncTest } = require('test-utils.js');
|
|
const { shim } = require('lib/shim.js');
|
|
const fs = require('fs-extra');
|
|
const Folder = require('lib/models/Folder.js');
|
|
const Note = require('lib/models/Note.js');
|
|
const Resource = require('lib/models/Resource.js');
|
|
const ResourceFetcher = require('lib/services/ResourceFetcher');
|
|
const Tag = require('lib/models/Tag.js');
|
|
const { Database } = require('lib/database.js');
|
|
const Setting = require('lib/models/Setting.js');
|
|
const MasterKey = require('lib/models/MasterKey');
|
|
const BaseItem = require('lib/models/BaseItem.js');
|
|
const Revision = require('lib/models/Revision.js');
|
|
const BaseModel = require('lib/BaseModel.js');
|
|
const SyncTargetRegistry = require('lib/SyncTargetRegistry.js');
|
|
const WelcomeUtils = require('lib/WelcomeUtils');
|
|
|
|
process.on('unhandledRejection', (reason, p) => {
|
|
console.log('Unhandled Rejection at: Promise', p, 'reason:', reason);
|
|
});
|
|
|
|
jasmine.DEFAULT_TIMEOUT_INTERVAL = 60000 + 30000; // The first test is slow because the database needs to be built
|
|
|
|
async function allNotesFolders() {
|
|
const folders = await Folder.all();
|
|
const notes = await Note.all();
|
|
return folders.concat(notes);
|
|
}
|
|
|
|
async function remoteItemsByTypes(types) {
|
|
const list = await fileApi().list();
|
|
if (list.has_more) throw new Error('Not implemented!!!');
|
|
const files = list.items;
|
|
|
|
const output = [];
|
|
for (const file of files) {
|
|
const remoteContent = await fileApi().get(file.path);
|
|
const content = await BaseItem.unserialize(remoteContent);
|
|
if (types.indexOf(content.type_) < 0) continue;
|
|
output.push(content);
|
|
}
|
|
return output;
|
|
}
|
|
|
|
async function remoteNotesAndFolders() {
|
|
return remoteItemsByTypes([BaseModel.TYPE_NOTE, BaseModel.TYPE_FOLDER]);
|
|
}
|
|
|
|
async function remoteNotesFoldersResources() {
|
|
return remoteItemsByTypes([BaseModel.TYPE_NOTE, BaseModel.TYPE_FOLDER, BaseModel.TYPE_RESOURCE]);
|
|
}
|
|
|
|
async function remoteResources() {
|
|
return remoteItemsByTypes([BaseModel.TYPE_RESOURCE]);
|
|
}
|
|
|
|
async function localNotesFoldersSameAsRemote(locals, expect) {
|
|
let error = null;
|
|
try {
|
|
const nf = await remoteNotesAndFolders();
|
|
expect(locals.length).toBe(nf.length);
|
|
|
|
for (let i = 0; i < locals.length; i++) {
|
|
const dbItem = locals[i];
|
|
const path = BaseItem.systemPath(dbItem);
|
|
const remote = await fileApi().stat(path);
|
|
|
|
expect(!!remote).toBe(true);
|
|
if (!remote) continue;
|
|
|
|
let remoteContent = await fileApi().get(path);
|
|
|
|
remoteContent = dbItem.type_ == BaseModel.TYPE_NOTE ? await Note.unserialize(remoteContent) : await Folder.unserialize(remoteContent);
|
|
expect(remoteContent.title).toBe(dbItem.title);
|
|
}
|
|
} catch (e) {
|
|
error = e;
|
|
}
|
|
|
|
expect(error).toBe(null);
|
|
}
|
|
|
|
let insideBeforeEach = false;
|
|
|
|
describe('synchronizer', function() {
|
|
|
|
beforeEach(async (done) => {
|
|
insideBeforeEach = true;
|
|
|
|
await setupDatabaseAndSynchronizer(1);
|
|
await setupDatabaseAndSynchronizer(2);
|
|
await switchClient(1);
|
|
done();
|
|
|
|
insideBeforeEach = false;
|
|
});
|
|
|
|
it('should create remote items', asyncTest(async () => {
|
|
const folder = await Folder.save({ title: 'folder1' });
|
|
await Note.save({ title: 'un', parent_id: folder.id });
|
|
|
|
const all = await allNotesFolders();
|
|
|
|
await synchronizer().start();
|
|
|
|
await localNotesFoldersSameAsRemote(all, expect);
|
|
}));
|
|
|
|
it('should update remote items', asyncTest(async () => {
|
|
const folder = await Folder.save({ title: 'folder1' });
|
|
const note = await Note.save({ title: 'un', parent_id: folder.id });
|
|
await synchronizer().start();
|
|
|
|
await Note.save({ title: 'un UPDATE', id: note.id });
|
|
|
|
const all = await allNotesFolders();
|
|
await synchronizer().start();
|
|
|
|
await localNotesFoldersSameAsRemote(all, expect);
|
|
}));
|
|
|
|
it('should create local items', asyncTest(async () => {
|
|
const folder = await Folder.save({ title: 'folder1' });
|
|
await Note.save({ title: 'un', parent_id: folder.id });
|
|
await synchronizer().start();
|
|
|
|
await switchClient(2);
|
|
|
|
await synchronizer().start();
|
|
|
|
const all = await allNotesFolders();
|
|
|
|
await localNotesFoldersSameAsRemote(all, expect);
|
|
}));
|
|
|
|
it('should update local items', asyncTest(async () => {
|
|
const folder1 = await Folder.save({ title: 'folder1' });
|
|
const note1 = await Note.save({ title: 'un', parent_id: folder1.id });
|
|
await synchronizer().start();
|
|
|
|
await switchClient(2);
|
|
|
|
await synchronizer().start();
|
|
|
|
await sleep(0.1);
|
|
|
|
let note2 = await Note.load(note1.id);
|
|
note2.title = 'Updated on client 2';
|
|
await Note.save(note2);
|
|
note2 = await Note.load(note2.id);
|
|
|
|
await synchronizer().start();
|
|
|
|
await switchClient(1);
|
|
|
|
await synchronizer().start();
|
|
|
|
const all = await allNotesFolders();
|
|
|
|
await localNotesFoldersSameAsRemote(all, expect);
|
|
}));
|
|
|
|
it('should resolve note conflicts', asyncTest(async () => {
|
|
const folder1 = await Folder.save({ title: 'folder1' });
|
|
const note1 = await Note.save({ title: 'un', parent_id: folder1.id });
|
|
await synchronizer().start();
|
|
|
|
await switchClient(2);
|
|
|
|
await synchronizer().start();
|
|
let note2 = await Note.load(note1.id);
|
|
note2.title = 'Updated on client 2';
|
|
await Note.save(note2);
|
|
note2 = await Note.load(note2.id);
|
|
await synchronizer().start();
|
|
|
|
await switchClient(1);
|
|
|
|
let note2conf = await Note.load(note1.id);
|
|
note2conf.title = 'Updated on client 1';
|
|
await Note.save(note2conf);
|
|
note2conf = await Note.load(note1.id);
|
|
await synchronizer().start();
|
|
const conflictedNotes = await Note.conflictedNotes();
|
|
expect(conflictedNotes.length).toBe(1);
|
|
|
|
// Other than the id (since the conflicted note is a duplicate), and the is_conflict property
|
|
// the conflicted and original note must be the same in every way, to make sure no data has been lost.
|
|
const conflictedNote = conflictedNotes[0];
|
|
expect(conflictedNote.id == note2conf.id).toBe(false);
|
|
for (const n in conflictedNote) {
|
|
if (!conflictedNote.hasOwnProperty(n)) continue;
|
|
if (n == 'id' || n == 'is_conflict') continue;
|
|
expect(conflictedNote[n]).toBe(note2conf[n], `Property: ${n}`);
|
|
}
|
|
|
|
const noteUpdatedFromRemote = await Note.load(note1.id);
|
|
for (const n in noteUpdatedFromRemote) {
|
|
if (!noteUpdatedFromRemote.hasOwnProperty(n)) continue;
|
|
expect(noteUpdatedFromRemote[n]).toBe(note2[n], `Property: ${n}`);
|
|
}
|
|
}));
|
|
|
|
it('should resolve folders conflicts', asyncTest(async () => {
|
|
const folder1 = await Folder.save({ title: 'folder1' });
|
|
const note1 = await Note.save({ title: 'un', parent_id: folder1.id });
|
|
await synchronizer().start();
|
|
|
|
await switchClient(2); // ----------------------------------
|
|
|
|
await synchronizer().start();
|
|
|
|
await sleep(0.1);
|
|
|
|
let folder1_modRemote = await Folder.load(folder1.id);
|
|
folder1_modRemote.title = 'folder1 UPDATE CLIENT 2';
|
|
await Folder.save(folder1_modRemote);
|
|
folder1_modRemote = await Folder.load(folder1_modRemote.id);
|
|
|
|
await synchronizer().start();
|
|
|
|
await switchClient(1); // ----------------------------------
|
|
|
|
await sleep(0.1);
|
|
|
|
let folder1_modLocal = await Folder.load(folder1.id);
|
|
folder1_modLocal.title = 'folder1 UPDATE CLIENT 1';
|
|
await Folder.save(folder1_modLocal);
|
|
folder1_modLocal = await Folder.load(folder1.id);
|
|
|
|
await synchronizer().start();
|
|
|
|
const folder1_final = await Folder.load(folder1.id);
|
|
expect(folder1_final.title).toBe(folder1_modRemote.title);
|
|
}));
|
|
|
|
it('should delete remote notes', asyncTest(async () => {
|
|
const folder1 = await Folder.save({ title: 'folder1' });
|
|
const note1 = await Note.save({ title: 'un', parent_id: folder1.id });
|
|
await synchronizer().start();
|
|
|
|
await switchClient(2);
|
|
|
|
await synchronizer().start();
|
|
|
|
await sleep(0.1);
|
|
|
|
await Note.delete(note1.id);
|
|
|
|
await synchronizer().start();
|
|
|
|
const remotes = await remoteNotesAndFolders();
|
|
expect(remotes.length).toBe(1);
|
|
expect(remotes[0].id).toBe(folder1.id);
|
|
|
|
const deletedItems = await BaseItem.deletedItems(syncTargetId());
|
|
expect(deletedItems.length).toBe(0);
|
|
}));
|
|
|
|
it('should not created deleted_items entries for items deleted via sync', asyncTest(async () => {
|
|
const folder1 = await Folder.save({ title: 'folder1' });
|
|
const note1 = await Note.save({ title: 'un', parent_id: folder1.id });
|
|
await synchronizer().start();
|
|
|
|
await switchClient(2);
|
|
|
|
await synchronizer().start();
|
|
await Folder.delete(folder1.id);
|
|
await synchronizer().start();
|
|
|
|
await switchClient(1);
|
|
|
|
await synchronizer().start();
|
|
const deletedItems = await BaseItem.deletedItems(syncTargetId());
|
|
expect(deletedItems.length).toBe(0);
|
|
}));
|
|
|
|
it('should delete local notes', asyncTest(async () => {
|
|
// For these tests we pass the context around for each user. This is to make sure that the "deletedItemsProcessed"
|
|
// property of the basicDelta() function is cleared properly at the end of a sync operation. If it is not cleared
|
|
// it means items will no longer be deleted locally via sync.
|
|
|
|
const folder1 = await Folder.save({ title: 'folder1' });
|
|
const note1 = await Note.save({ title: 'un', parent_id: folder1.id });
|
|
const note2 = await Note.save({ title: 'deux', parent_id: folder1.id });
|
|
let context1 = await synchronizer().start();
|
|
|
|
await switchClient(2);
|
|
|
|
let context2 = await synchronizer().start();
|
|
await Note.delete(note1.id);
|
|
context2 = await synchronizer().start({ context: context2 });
|
|
|
|
await switchClient(1);
|
|
|
|
context1 = await synchronizer().start({ context: context1 });
|
|
const items = await allNotesFolders();
|
|
expect(items.length).toBe(2);
|
|
const deletedItems = await BaseItem.deletedItems(syncTargetId());
|
|
expect(deletedItems.length).toBe(0);
|
|
await Note.delete(note2.id);
|
|
context1 = await synchronizer().start({ context: context1 });
|
|
}));
|
|
|
|
it('should delete remote folder', asyncTest(async () => {
|
|
const folder1 = await Folder.save({ title: 'folder1' });
|
|
const folder2 = await Folder.save({ title: 'folder2' });
|
|
await synchronizer().start();
|
|
|
|
await switchClient(2);
|
|
|
|
await synchronizer().start();
|
|
|
|
await sleep(0.1);
|
|
|
|
await Folder.delete(folder2.id);
|
|
|
|
await synchronizer().start();
|
|
|
|
const all = await allNotesFolders();
|
|
await localNotesFoldersSameAsRemote(all, expect);
|
|
}));
|
|
|
|
it('should delete local folder', asyncTest(async () => {
|
|
const folder1 = await Folder.save({ title: 'folder1' });
|
|
const folder2 = await Folder.save({ title: 'folder2' });
|
|
const context1 = await synchronizer().start();
|
|
|
|
await switchClient(2);
|
|
|
|
const context2 = await synchronizer().start();
|
|
await Folder.delete(folder2.id);
|
|
await synchronizer().start({ context: context2 });
|
|
|
|
await switchClient(1);
|
|
|
|
await synchronizer().start({ context: context1 });
|
|
const items = await allNotesFolders();
|
|
await localNotesFoldersSameAsRemote(items, expect);
|
|
}));
|
|
|
|
it('should resolve conflict if remote folder has been deleted, but note has been added to folder locally', asyncTest(async () => {
|
|
const folder1 = await Folder.save({ title: 'folder1' });
|
|
await synchronizer().start();
|
|
|
|
await switchClient(2);
|
|
|
|
await synchronizer().start();
|
|
await Folder.delete(folder1.id);
|
|
await synchronizer().start();
|
|
|
|
await switchClient(1);
|
|
|
|
const note = await Note.save({ title: 'note1', parent_id: folder1.id });
|
|
await synchronizer().start();
|
|
const items = await allNotesFolders();
|
|
expect(items.length).toBe(1);
|
|
expect(items[0].title).toBe('note1');
|
|
expect(items[0].is_conflict).toBe(1);
|
|
}));
|
|
|
|
it('should resolve conflict if note has been deleted remotely and locally', asyncTest(async () => {
|
|
const folder = await Folder.save({ title: 'folder' });
|
|
const note = await Note.save({ title: 'note', parent_id: folder.title });
|
|
await synchronizer().start();
|
|
|
|
await switchClient(2);
|
|
|
|
await synchronizer().start();
|
|
await Note.delete(note.id);
|
|
await synchronizer().start();
|
|
|
|
await switchClient(1);
|
|
|
|
await Note.delete(note.id);
|
|
await synchronizer().start();
|
|
|
|
const items = await allNotesFolders();
|
|
expect(items.length).toBe(1);
|
|
expect(items[0].title).toBe('folder');
|
|
|
|
await localNotesFoldersSameAsRemote(items, expect);
|
|
}));
|
|
|
|
it('should cross delete all folders', asyncTest(async () => {
|
|
// If client1 and 2 have two folders, client 1 deletes item 1 and client
|
|
// 2 deletes item 2, they should both end up with no items after sync.
|
|
|
|
const folder1 = await Folder.save({ title: 'folder1' });
|
|
const folder2 = await Folder.save({ title: 'folder2' });
|
|
await synchronizer().start();
|
|
|
|
await switchClient(2);
|
|
|
|
await synchronizer().start();
|
|
|
|
await sleep(0.1);
|
|
|
|
await Folder.delete(folder1.id);
|
|
|
|
await switchClient(1);
|
|
|
|
await Folder.delete(folder2.id);
|
|
|
|
await synchronizer().start();
|
|
|
|
await switchClient(2);
|
|
|
|
await synchronizer().start();
|
|
|
|
const items2 = await allNotesFolders();
|
|
|
|
await switchClient(1);
|
|
|
|
await synchronizer().start();
|
|
|
|
const items1 = await allNotesFolders();
|
|
|
|
expect(items1.length).toBe(0);
|
|
expect(items1.length).toBe(items2.length);
|
|
}));
|
|
|
|
it('should handle conflict when remote note is deleted then local note is modified', asyncTest(async () => {
|
|
const folder1 = await Folder.save({ title: 'folder1' });
|
|
const note1 = await Note.save({ title: 'un', parent_id: folder1.id });
|
|
await synchronizer().start();
|
|
|
|
await switchClient(2);
|
|
|
|
await synchronizer().start();
|
|
|
|
await sleep(0.1);
|
|
|
|
await Note.delete(note1.id);
|
|
|
|
await synchronizer().start();
|
|
|
|
await switchClient(1);
|
|
|
|
const newTitle = 'Modified after having been deleted';
|
|
await Note.save({ id: note1.id, title: newTitle });
|
|
|
|
await synchronizer().start();
|
|
|
|
const conflictedNotes = await Note.conflictedNotes();
|
|
|
|
expect(conflictedNotes.length).toBe(1);
|
|
expect(conflictedNotes[0].title).toBe(newTitle);
|
|
|
|
const unconflictedNotes = await Note.unconflictedNotes();
|
|
|
|
expect(unconflictedNotes.length).toBe(0);
|
|
}));
|
|
|
|
it('should handle conflict when remote folder is deleted then local folder is renamed', asyncTest(async () => {
|
|
const folder1 = await Folder.save({ title: 'folder1' });
|
|
const folder2 = await Folder.save({ title: 'folder2' });
|
|
const note1 = await Note.save({ title: 'un', parent_id: folder1.id });
|
|
await synchronizer().start();
|
|
|
|
await switchClient(2);
|
|
|
|
await synchronizer().start();
|
|
|
|
await sleep(0.1);
|
|
|
|
await Folder.delete(folder1.id);
|
|
|
|
await synchronizer().start();
|
|
|
|
await switchClient(1);
|
|
|
|
await sleep(0.1);
|
|
|
|
const newTitle = 'Modified after having been deleted';
|
|
await Folder.save({ id: folder1.id, title: newTitle });
|
|
|
|
await synchronizer().start();
|
|
|
|
const items = await allNotesFolders();
|
|
|
|
expect(items.length).toBe(1);
|
|
}));
|
|
|
|
it('should allow duplicate folder titles', asyncTest(async () => {
|
|
const localF1 = await Folder.save({ title: 'folder' });
|
|
|
|
await switchClient(2);
|
|
|
|
let remoteF2 = await Folder.save({ title: 'folder' });
|
|
await synchronizer().start();
|
|
|
|
await switchClient(1);
|
|
|
|
await sleep(0.1);
|
|
|
|
await synchronizer().start();
|
|
|
|
const localF2 = await Folder.load(remoteF2.id);
|
|
|
|
expect(localF2.title == remoteF2.title).toBe(true);
|
|
|
|
// Then that folder that has been renamed locally should be set in such a way
|
|
// that synchronizing it applies the title change remotely, and that new title
|
|
// should be retrieved by client 2.
|
|
|
|
await synchronizer().start();
|
|
|
|
await switchClient(2);
|
|
await sleep(0.1);
|
|
|
|
await synchronizer().start();
|
|
|
|
remoteF2 = await Folder.load(remoteF2.id);
|
|
|
|
expect(remoteF2.title == localF2.title).toBe(true);
|
|
}));
|
|
|
|
async function shoudSyncTagTest(withEncryption) {
|
|
let masterKey = null;
|
|
if (withEncryption) {
|
|
Setting.setValue('encryption.enabled', true);
|
|
masterKey = await loadEncryptionMasterKey();
|
|
}
|
|
|
|
const f1 = await Folder.save({ title: 'folder' });
|
|
const n1 = await Note.save({ title: 'mynote' });
|
|
const n2 = await Note.save({ title: 'mynote2' });
|
|
const tag = await Tag.save({ title: 'mytag' });
|
|
let context1 = await synchronizer().start();
|
|
|
|
await switchClient(2);
|
|
|
|
let context2 = await synchronizer().start();
|
|
if (withEncryption) {
|
|
const masterKey_2 = await MasterKey.load(masterKey.id);
|
|
await encryptionService().loadMasterKey_(masterKey_2, '123456', true);
|
|
const t = await Tag.load(tag.id);
|
|
await Tag.decrypt(t);
|
|
}
|
|
const remoteTag = await Tag.loadByTitle(tag.title);
|
|
expect(!!remoteTag).toBe(true);
|
|
expect(remoteTag.id).toBe(tag.id);
|
|
await Tag.addNote(remoteTag.id, n1.id);
|
|
await Tag.addNote(remoteTag.id, n2.id);
|
|
let noteIds = await Tag.noteIds(tag.id);
|
|
expect(noteIds.length).toBe(2);
|
|
context2 = await synchronizer().start({ context: context2 });
|
|
|
|
await switchClient(1);
|
|
|
|
context1 = await synchronizer().start({ context: context1 });
|
|
let remoteNoteIds = await Tag.noteIds(tag.id);
|
|
expect(remoteNoteIds.length).toBe(2);
|
|
await Tag.removeNote(tag.id, n1.id);
|
|
remoteNoteIds = await Tag.noteIds(tag.id);
|
|
expect(remoteNoteIds.length).toBe(1);
|
|
context1 = await synchronizer().start({ context: context1 });
|
|
|
|
await switchClient(2);
|
|
|
|
context2 = await synchronizer().start({ context: context2 });
|
|
noteIds = await Tag.noteIds(tag.id);
|
|
expect(noteIds.length).toBe(1);
|
|
expect(remoteNoteIds[0]).toBe(noteIds[0]);
|
|
}
|
|
|
|
it('should sync tags', asyncTest(async () => {
|
|
await shoudSyncTagTest(false);
|
|
}));
|
|
|
|
it('should sync encrypted tags', asyncTest(async () => {
|
|
await shoudSyncTagTest(true);
|
|
}));
|
|
|
|
it('should sync tag deletion', asyncTest(async () => {
|
|
const f1 = await Folder.save({ title: 'folder' });
|
|
const n1 = await Note.save({ title: 'mynote', parent_id: f1.id });
|
|
const tag = await Tag.saveNested({}, 'a/b/c');
|
|
await Tag.addNote(tag.id, n1.id);
|
|
await synchronizer().start();
|
|
|
|
await switchClient(2);
|
|
await synchronizer().start();
|
|
let taga = await Tag.loadByTitle('a');
|
|
let tagb = await Tag.loadByTitle('a/b');
|
|
let tagc = await Tag.loadByTitle('a/b/c');
|
|
expect(taga).toBeDefined();
|
|
expect(tagb).toBeDefined();
|
|
expect(tagc).toBeDefined();
|
|
|
|
// Should remove both parent and children tags in this case
|
|
await Tag.delete(tagb.id);
|
|
await synchronizer().start();
|
|
|
|
await switchClient(1);
|
|
await synchronizer().start();
|
|
taga = await Tag.loadByTitle('a');
|
|
tagb = await Tag.loadByTitle('a/b');
|
|
tagc = await Tag.loadByTitle('a/b/c');
|
|
expect(taga).not.toBeDefined();
|
|
expect(tagb).not.toBeDefined();
|
|
expect(tagc).not.toBeDefined();
|
|
}));
|
|
|
|
it('should sync child tag deletion', asyncTest(async () => {
|
|
const f1 = await Folder.save({ title: 'folder' });
|
|
const n1 = await Note.save({ title: 'mynote', parent_id: f1.id });
|
|
const tag1 = await Tag.saveNested({}, 'a/b/c/d');
|
|
const tag2 = await Tag.saveNested({}, 'a/b/d');
|
|
await Tag.addNote(tag1.id, n1.id);
|
|
await Tag.addNote(tag2.id, n1.id);
|
|
let taga = await Tag.loadByTitle('a');
|
|
let tagb = await Tag.loadByTitle('a/b');
|
|
let tagc = await Tag.loadByTitle('a/b/c');
|
|
let tagabcd = await Tag.loadByTitle('a/b/c/d');
|
|
let tagabd = await Tag.loadByTitle('a/b/d');
|
|
await synchronizer().start();
|
|
|
|
await switchClient(2);
|
|
await synchronizer().start();
|
|
const taga2 = await Tag.loadByTitle('a');
|
|
const tagb2 = await Tag.loadByTitle('a/b');
|
|
const tagc2 = await Tag.loadByTitle('a/b/c');
|
|
const tagabcd2 = await Tag.loadByTitle('a/b/c/d');
|
|
const tagabd2 = await Tag.loadByTitle('a/b/d');
|
|
expect(taga2).toBeDefined();
|
|
expect(tagb2).toBeDefined();
|
|
expect(tagc2).toBeDefined();
|
|
expect(tagabcd2).toBeDefined();
|
|
expect(tagabd2).toBeDefined();
|
|
expect(taga2.id).toBe(taga.id);
|
|
expect(tagb2.id).toBe(tagb.id);
|
|
expect(tagc2.id).toBe(tagc.id);
|
|
expect(tagabcd2.id).toBe(tagabcd.id);
|
|
expect(tagabd2.id).toBe(tagabd.id);
|
|
|
|
// Should remove children tags in this case
|
|
await Tag.delete(tagc.id);
|
|
await synchronizer().start();
|
|
|
|
await switchClient(1);
|
|
await synchronizer().start();
|
|
taga = await Tag.loadByTitle('a');
|
|
tagb = await Tag.loadByTitle('a/b');
|
|
tagc = await Tag.loadByTitle('a/b/c');
|
|
tagabcd = await Tag.loadByTitle('a/b/c/d');
|
|
tagabd = await Tag.loadByTitle('a/b/d');
|
|
expect(taga).toBeDefined();
|
|
expect(tagb).toBeDefined();
|
|
expect(tagc).not.toBeDefined();
|
|
expect(tagabcd).not.toBeDefined();
|
|
expect(tagabd).toBeDefined();
|
|
expect(taga.id).toBe(taga2.id);
|
|
expect(tagb.id).toBe(tagb2.id);
|
|
expect(tagabd.id).toBe(tagabd2.id);
|
|
}));
|
|
|
|
it('should not sync notes with conflicts', asyncTest(async () => {
|
|
const f1 = await Folder.save({ title: 'folder' });
|
|
const n1 = await Note.save({ title: 'mynote', parent_id: f1.id, is_conflict: 1 });
|
|
await synchronizer().start();
|
|
|
|
await switchClient(2);
|
|
|
|
await synchronizer().start();
|
|
const notes = await Note.all();
|
|
const folders = await Folder.all();
|
|
expect(notes.length).toBe(0);
|
|
expect(folders.length).toBe(1);
|
|
}));
|
|
|
|
it('should not try to delete on remote conflicted notes that have been deleted', asyncTest(async () => {
|
|
const f1 = await Folder.save({ title: 'folder' });
|
|
const n1 = await Note.save({ title: 'mynote', parent_id: f1.id });
|
|
await synchronizer().start();
|
|
|
|
await switchClient(2);
|
|
|
|
await synchronizer().start();
|
|
await Note.save({ id: n1.id, is_conflict: 1 });
|
|
await Note.delete(n1.id);
|
|
const deletedItems = await BaseItem.deletedItems(syncTargetId());
|
|
|
|
expect(deletedItems.length).toBe(0);
|
|
}));
|
|
|
|
async function ignorableNoteConflictTest(withEncryption) {
|
|
if (withEncryption) {
|
|
Setting.setValue('encryption.enabled', true);
|
|
await loadEncryptionMasterKey();
|
|
}
|
|
|
|
const folder1 = await Folder.save({ title: 'folder1' });
|
|
const note1 = await Note.save({ title: 'un', is_todo: 1, parent_id: folder1.id });
|
|
await synchronizer().start();
|
|
|
|
await switchClient(2);
|
|
|
|
await synchronizer().start();
|
|
if (withEncryption) {
|
|
await loadEncryptionMasterKey(null, true);
|
|
await decryptionWorker().start();
|
|
}
|
|
let note2 = await Note.load(note1.id);
|
|
note2.todo_completed = time.unixMs() - 1;
|
|
await Note.save(note2);
|
|
note2 = await Note.load(note2.id);
|
|
await synchronizer().start();
|
|
|
|
await switchClient(1);
|
|
|
|
let note2conf = await Note.load(note1.id);
|
|
note2conf.todo_completed = time.unixMs();
|
|
await Note.save(note2conf);
|
|
note2conf = await Note.load(note1.id);
|
|
await synchronizer().start();
|
|
|
|
if (!withEncryption) {
|
|
// That was previously a common conflict:
|
|
// - Client 1 mark todo as "done", and sync
|
|
// - Client 2 doesn't sync, mark todo as "done" todo. Then sync.
|
|
// In theory it is a conflict because the todo_completed dates are different
|
|
// but in practice it doesn't matter, we can just take the date when the
|
|
// todo was marked as "done" the first time.
|
|
|
|
const conflictedNotes = await Note.conflictedNotes();
|
|
expect(conflictedNotes.length).toBe(0);
|
|
|
|
const notes = await Note.all();
|
|
expect(notes.length).toBe(1);
|
|
expect(notes[0].id).toBe(note1.id);
|
|
expect(notes[0].todo_completed).toBe(note2.todo_completed);
|
|
} else {
|
|
// If the notes are encrypted however it's not possible to do this kind of
|
|
// smart conflict resolving since we don't know the content, so in that
|
|
// case it's handled as a regular conflict.
|
|
|
|
const conflictedNotes = await Note.conflictedNotes();
|
|
expect(conflictedNotes.length).toBe(1);
|
|
|
|
const notes = await Note.all();
|
|
expect(notes.length).toBe(2);
|
|
}
|
|
}
|
|
|
|
it('should not consider it is a conflict if neither the title nor body of the note have changed', asyncTest(async () => {
|
|
await ignorableNoteConflictTest(false);
|
|
}));
|
|
|
|
it('should always handle conflict if local or remote are encrypted', asyncTest(async () => {
|
|
await ignorableNoteConflictTest(true);
|
|
}));
|
|
|
|
it('items should be downloaded again when user cancels in the middle of delta operation', asyncTest(async () => {
|
|
const folder1 = await Folder.save({ title: 'folder1' });
|
|
const note1 = await Note.save({ title: 'un', is_todo: 1, parent_id: folder1.id });
|
|
await synchronizer().start();
|
|
|
|
await switchClient(2);
|
|
|
|
synchronizer().testingHooks_ = ['cancelDeltaLoop2'];
|
|
const context = await synchronizer().start();
|
|
let notes = await Note.all();
|
|
expect(notes.length).toBe(0);
|
|
|
|
synchronizer().testingHooks_ = [];
|
|
await synchronizer().start({ context: context });
|
|
notes = await Note.all();
|
|
expect(notes.length).toBe(1);
|
|
}));
|
|
|
|
it('should skip items that cannot be synced', asyncTest(async () => {
|
|
const folder1 = await Folder.save({ title: 'folder1' });
|
|
const note1 = await Note.save({ title: 'un', is_todo: 1, parent_id: folder1.id });
|
|
const noteId = note1.id;
|
|
await synchronizer().start();
|
|
let disabledItems = await BaseItem.syncDisabledItems(syncTargetId());
|
|
expect(disabledItems.length).toBe(0);
|
|
await Note.save({ id: noteId, title: 'un mod' });
|
|
synchronizer().testingHooks_ = ['notesRejectedByTarget'];
|
|
await synchronizer().start();
|
|
synchronizer().testingHooks_ = [];
|
|
await synchronizer().start(); // Another sync to check that this item is now excluded from sync
|
|
|
|
await switchClient(2);
|
|
|
|
await synchronizer().start();
|
|
const notes = await Note.all();
|
|
expect(notes.length).toBe(1);
|
|
expect(notes[0].title).toBe('un');
|
|
|
|
await switchClient(1);
|
|
|
|
disabledItems = await BaseItem.syncDisabledItems(syncTargetId());
|
|
expect(disabledItems.length).toBe(1);
|
|
}));
|
|
|
|
it('notes and folders should get encrypted when encryption is enabled', asyncTest(async () => {
|
|
Setting.setValue('encryption.enabled', true);
|
|
const masterKey = await loadEncryptionMasterKey();
|
|
const folder1 = await Folder.save({ title: 'folder1' });
|
|
let note1 = await Note.save({ title: 'un', body: 'to be encrypted', parent_id: folder1.id });
|
|
await synchronizer().start();
|
|
// After synchronisation, remote items should be encrypted but local ones remain plain text
|
|
note1 = await Note.load(note1.id);
|
|
expect(note1.title).toBe('un');
|
|
|
|
await switchClient(2);
|
|
|
|
await synchronizer().start();
|
|
let folder1_2 = await Folder.load(folder1.id);
|
|
let note1_2 = await Note.load(note1.id);
|
|
const masterKey_2 = await MasterKey.load(masterKey.id);
|
|
// On this side however it should be received encrypted
|
|
expect(!note1_2.title).toBe(true);
|
|
expect(!folder1_2.title).toBe(true);
|
|
expect(!!note1_2.encryption_cipher_text).toBe(true);
|
|
expect(!!folder1_2.encryption_cipher_text).toBe(true);
|
|
// Master key is already encrypted so it does not get re-encrypted during sync
|
|
expect(masterKey_2.content).toBe(masterKey.content);
|
|
expect(masterKey_2.checksum).toBe(masterKey.checksum);
|
|
// Now load the master key we got from client 1 and try to decrypt
|
|
await encryptionService().loadMasterKey_(masterKey_2, '123456', true);
|
|
// Get the decrypted items back
|
|
await Folder.decrypt(folder1_2);
|
|
await Note.decrypt(note1_2);
|
|
folder1_2 = await Folder.load(folder1.id);
|
|
note1_2 = await Note.load(note1.id);
|
|
// Check that properties match the original items. Also check
|
|
// the encryption did not affect the updated_time timestamp.
|
|
expect(note1_2.title).toBe(note1.title);
|
|
expect(note1_2.body).toBe(note1.body);
|
|
expect(note1_2.updated_time).toBe(note1.updated_time);
|
|
expect(!note1_2.encryption_cipher_text).toBe(true);
|
|
expect(folder1_2.title).toBe(folder1.title);
|
|
expect(folder1_2.updated_time).toBe(folder1.updated_time);
|
|
expect(!folder1_2.encryption_cipher_text).toBe(true);
|
|
}));
|
|
|
|
it('should enable encryption automatically when downloading new master key (and none was previously available)',asyncTest(async () => {
|
|
// Enable encryption on client 1 and sync an item
|
|
Setting.setValue('encryption.enabled', true);
|
|
await loadEncryptionMasterKey();
|
|
let folder1 = await Folder.save({ title: 'folder1' });
|
|
await synchronizer().start();
|
|
|
|
await switchClient(2);
|
|
|
|
// Synchronising should enable encryption since we're going to get a master key
|
|
expect(Setting.value('encryption.enabled')).toBe(false);
|
|
await synchronizer().start();
|
|
expect(Setting.value('encryption.enabled')).toBe(true);
|
|
|
|
// Check that we got the master key from client 1
|
|
const masterKey = (await MasterKey.all())[0];
|
|
expect(!!masterKey).toBe(true);
|
|
|
|
// Since client 2 hasn't supplied a password yet, no master key is currently loaded
|
|
expect(encryptionService().loadedMasterKeyIds().length).toBe(0);
|
|
|
|
// If we sync now, nothing should be sent to target since we don't have a password.
|
|
// Technically it's incorrect to set the property of an encrypted variable but it allows confirming
|
|
// that encryption doesn't work if user hasn't supplied a password.
|
|
await BaseItem.forceSync(folder1.id);
|
|
await synchronizer().start();
|
|
|
|
await switchClient(1);
|
|
|
|
await synchronizer().start();
|
|
folder1 = await Folder.load(folder1.id);
|
|
expect(folder1.title).toBe('folder1'); // Still at old value
|
|
|
|
await switchClient(2);
|
|
|
|
// Now client 2 set the master key password
|
|
Setting.setObjectKey('encryption.passwordCache', masterKey.id, '123456');
|
|
await encryptionService().loadMasterKeysFromSettings();
|
|
|
|
// Now that master key should be loaded
|
|
expect(encryptionService().loadedMasterKeyIds()[0]).toBe(masterKey.id);
|
|
|
|
// Decrypt all the data. Now change the title and sync again - this time the changes should be transmitted
|
|
await decryptionWorker().start();
|
|
await Folder.save({ id: folder1.id, title: 'change test' });
|
|
|
|
// If we sync now, this time client 1 should get the changes we did earlier
|
|
await synchronizer().start();
|
|
|
|
await switchClient(1);
|
|
|
|
await synchronizer().start();
|
|
// Decrypt the data we just got
|
|
await decryptionWorker().start();
|
|
folder1 = await Folder.load(folder1.id);
|
|
expect(folder1.title).toBe('change test'); // Got title from client 2
|
|
}));
|
|
|
|
it('should encrypt existing notes too when enabling E2EE', asyncTest(async () => {
|
|
// First create a folder, without encryption enabled, and sync it
|
|
const folder1 = await Folder.save({ title: 'folder1' });
|
|
await synchronizer().start();
|
|
let files = await fileApi().list();
|
|
let content = await fileApi().get(files.items[0].path);
|
|
expect(content.indexOf('folder1') >= 0).toBe(true);
|
|
|
|
// Then enable encryption and sync again
|
|
let masterKey = await encryptionService().generateMasterKey('123456');
|
|
masterKey = await MasterKey.save(masterKey);
|
|
await encryptionService().enableEncryption(masterKey, '123456');
|
|
await encryptionService().loadMasterKeysFromSettings();
|
|
await synchronizer().start();
|
|
|
|
// Even though the folder has not been changed it should have been synced again so that
|
|
// an encrypted version of it replaces the decrypted version.
|
|
files = await fileApi().list();
|
|
expect(files.items.length).toBe(2);
|
|
// By checking that the folder title is not present, we can confirm that the item has indeed been encrypted
|
|
// One of the two items is the master key
|
|
content = await fileApi().get(files.items[0].path);
|
|
expect(content.indexOf('folder1') < 0).toBe(true);
|
|
content = await fileApi().get(files.items[1].path);
|
|
expect(content.indexOf('folder1') < 0).toBe(true);
|
|
}));
|
|
|
|
it('should sync resources', asyncTest(async () => {
|
|
while (insideBeforeEach) await time.msleep(500);
|
|
|
|
const folder1 = await Folder.save({ title: 'folder1' });
|
|
const note1 = await Note.save({ title: 'ma note', parent_id: folder1.id });
|
|
await shim.attachFileToNote(note1, `${__dirname}/../tests/support/photo.jpg`);
|
|
const resource1 = (await Resource.all())[0];
|
|
const resourcePath1 = Resource.fullPath(resource1);
|
|
await synchronizer().start();
|
|
expect((await remoteNotesFoldersResources()).length).toBe(3);
|
|
|
|
await switchClient(2);
|
|
|
|
await synchronizer().start();
|
|
const allResources = await Resource.all();
|
|
expect(allResources.length).toBe(1);
|
|
let resource1_2 = allResources[0];
|
|
let ls = await Resource.localState(resource1_2);
|
|
expect(resource1_2.id).toBe(resource1.id);
|
|
expect(ls.fetch_status).toBe(Resource.FETCH_STATUS_IDLE);
|
|
|
|
const fetcher = new ResourceFetcher(() => { return synchronizer().api(); });
|
|
fetcher.queueDownload_(resource1_2.id);
|
|
await fetcher.waitForAllFinished();
|
|
|
|
resource1_2 = await Resource.load(resource1.id);
|
|
ls = await Resource.localState(resource1_2);
|
|
expect(ls.fetch_status).toBe(Resource.FETCH_STATUS_DONE);
|
|
|
|
const resourcePath1_2 = Resource.fullPath(resource1_2);
|
|
expect(fileContentEqual(resourcePath1, resourcePath1_2)).toBe(true);
|
|
}));
|
|
|
|
it('should handle resource download errors', asyncTest(async () => {
|
|
while (insideBeforeEach) await time.msleep(500);
|
|
|
|
const folder1 = await Folder.save({ title: 'folder1' });
|
|
const note1 = await Note.save({ title: 'ma note', parent_id: folder1.id });
|
|
await shim.attachFileToNote(note1, `${__dirname}/../tests/support/photo.jpg`);
|
|
let resource1 = (await Resource.all())[0];
|
|
const resourcePath1 = Resource.fullPath(resource1);
|
|
await synchronizer().start();
|
|
|
|
await switchClient(2);
|
|
|
|
await synchronizer().start();
|
|
|
|
const fetcher = new ResourceFetcher(() => {
|
|
return {
|
|
// Simulate a failed download
|
|
get: () => { return new Promise((resolve, reject) => { reject(new Error('did not work')); }); },
|
|
};
|
|
});
|
|
fetcher.queueDownload_(resource1.id);
|
|
await fetcher.waitForAllFinished();
|
|
|
|
resource1 = await Resource.load(resource1.id);
|
|
const ls = await Resource.localState(resource1);
|
|
expect(ls.fetch_status).toBe(Resource.FETCH_STATUS_ERROR);
|
|
expect(ls.fetch_error).toBe('did not work');
|
|
}));
|
|
|
|
it('should set the resource file size if it is missing', asyncTest(async () => {
|
|
while (insideBeforeEach) await time.msleep(500);
|
|
|
|
const folder1 = await Folder.save({ title: 'folder1' });
|
|
const note1 = await Note.save({ title: 'ma note', parent_id: folder1.id });
|
|
await shim.attachFileToNote(note1, `${__dirname}/../tests/support/photo.jpg`);
|
|
await synchronizer().start();
|
|
|
|
await switchClient(2);
|
|
|
|
await synchronizer().start();
|
|
let r1 = (await Resource.all())[0];
|
|
await Resource.setFileSizeOnly(r1.id, -1);
|
|
r1 = await Resource.load(r1.id);
|
|
expect(r1.size).toBe(-1);
|
|
|
|
const fetcher = new ResourceFetcher(() => { return synchronizer().api(); });
|
|
fetcher.queueDownload_(r1.id);
|
|
await fetcher.waitForAllFinished();
|
|
r1 = await Resource.load(r1.id);
|
|
expect(r1.size).toBe(2720);
|
|
}));
|
|
|
|
it('should delete resources', asyncTest(async () => {
|
|
while (insideBeforeEach) await time.msleep(500);
|
|
|
|
const folder1 = await Folder.save({ title: 'folder1' });
|
|
const note1 = await Note.save({ title: 'ma note', parent_id: folder1.id });
|
|
await shim.attachFileToNote(note1, `${__dirname}/../tests/support/photo.jpg`);
|
|
const resource1 = (await Resource.all())[0];
|
|
const resourcePath1 = Resource.fullPath(resource1);
|
|
await synchronizer().start();
|
|
|
|
await switchClient(2);
|
|
|
|
await synchronizer().start();
|
|
let allResources = await Resource.all();
|
|
expect(allResources.length).toBe(1);
|
|
const all = await fileApi().list();
|
|
expect((await remoteNotesFoldersResources()).length).toBe(3);
|
|
await Resource.delete(resource1.id);
|
|
await synchronizer().start();
|
|
expect((await remoteNotesFoldersResources()).length).toBe(2);
|
|
|
|
const remoteBlob = await fileApi().stat(`.resource/${resource1.id}`);
|
|
expect(!remoteBlob).toBe(true);
|
|
|
|
await switchClient(1);
|
|
|
|
expect(await shim.fsDriver().exists(resourcePath1)).toBe(true);
|
|
await synchronizer().start();
|
|
allResources = await Resource.all();
|
|
expect(allResources.length).toBe(0);
|
|
expect(await shim.fsDriver().exists(resourcePath1)).toBe(false);
|
|
}));
|
|
|
|
it('should encryt resources', asyncTest(async () => {
|
|
Setting.setValue('encryption.enabled', true);
|
|
const masterKey = await loadEncryptionMasterKey();
|
|
|
|
const folder1 = await Folder.save({ title: 'folder1' });
|
|
const note1 = await Note.save({ title: 'ma note', parent_id: folder1.id });
|
|
await shim.attachFileToNote(note1, `${__dirname}/../tests/support/photo.jpg`);
|
|
const resource1 = (await Resource.all())[0];
|
|
const resourcePath1 = Resource.fullPath(resource1);
|
|
await synchronizer().start();
|
|
|
|
await switchClient(2);
|
|
|
|
await synchronizer().start();
|
|
Setting.setObjectKey('encryption.passwordCache', masterKey.id, '123456');
|
|
await encryptionService().loadMasterKeysFromSettings();
|
|
|
|
const fetcher = new ResourceFetcher(() => { return synchronizer().api(); });
|
|
fetcher.queueDownload_(resource1.id);
|
|
await fetcher.waitForAllFinished();
|
|
|
|
let resource1_2 = (await Resource.all())[0];
|
|
resource1_2 = await Resource.decrypt(resource1_2);
|
|
const resourcePath1_2 = Resource.fullPath(resource1_2);
|
|
|
|
expect(fileContentEqual(resourcePath1, resourcePath1_2)).toBe(true);
|
|
}));
|
|
|
|
it('should sync resource blob changes', asyncTest(async () => {
|
|
const tempFile = tempFilePath('txt');
|
|
await shim.fsDriver().writeFile(tempFile, '1234', 'utf8');
|
|
const folder1 = await Folder.save({ title: 'folder1' });
|
|
const note1 = await Note.save({ title: 'ma note', parent_id: folder1.id });
|
|
await shim.attachFileToNote(note1, tempFile);
|
|
await synchronizer().start();
|
|
|
|
await switchClient(2);
|
|
|
|
await synchronizer().start();
|
|
await resourceFetcher().start();
|
|
await resourceFetcher().waitForAllFinished();
|
|
let resource1_2 = (await Resource.all())[0];
|
|
const modFile = tempFilePath('txt');
|
|
await shim.fsDriver().writeFile(modFile, '1234 MOD', 'utf8');
|
|
await Resource.updateResourceBlobContent(resource1_2.id, modFile);
|
|
const originalSize = resource1_2.size;
|
|
resource1_2 = (await Resource.all())[0];
|
|
const newSize = resource1_2.size;
|
|
expect(originalSize).toBe(4);
|
|
expect(newSize).toBe(8);
|
|
await synchronizer().start();
|
|
|
|
await switchClient(1);
|
|
|
|
await synchronizer().start();
|
|
await resourceFetcher().start();
|
|
await resourceFetcher().waitForAllFinished();
|
|
const resource1_1 = (await Resource.all())[0];
|
|
expect(resource1_1.size).toBe(newSize);
|
|
expect(await Resource.resourceBlobContent(resource1_1.id, 'utf8')).toBe('1234 MOD');
|
|
}));
|
|
|
|
it('should handle resource conflicts', asyncTest(async () => {
|
|
{
|
|
const tempFile = tempFilePath('txt');
|
|
await shim.fsDriver().writeFile(tempFile, '1234', 'utf8');
|
|
const folder1 = await Folder.save({ title: 'folder1' });
|
|
const note1 = await Note.save({ title: 'ma note', parent_id: folder1.id });
|
|
await shim.attachFileToNote(note1, tempFile);
|
|
await synchronizer().start();
|
|
}
|
|
|
|
await switchClient(2);
|
|
|
|
{
|
|
await synchronizer().start();
|
|
await resourceFetcher().start();
|
|
await resourceFetcher().waitForAllFinished();
|
|
const resource = (await Resource.all())[0];
|
|
const modFile2 = tempFilePath('txt');
|
|
await shim.fsDriver().writeFile(modFile2, '1234 MOD 2', 'utf8');
|
|
await Resource.updateResourceBlobContent(resource.id, modFile2);
|
|
await synchronizer().start();
|
|
}
|
|
|
|
await switchClient(1);
|
|
|
|
{
|
|
// Going to modify a resource without syncing first, which will cause a conflict
|
|
const resource = (await Resource.all())[0];
|
|
const modFile1 = tempFilePath('txt');
|
|
await shim.fsDriver().writeFile(modFile1, '1234 MOD 1', 'utf8');
|
|
await Resource.updateResourceBlobContent(resource.id, modFile1);
|
|
await synchronizer().start(); // CONFLICT
|
|
|
|
// If we try to read the resource content now, it should throw because the local
|
|
// content has been moved to the conflict notebook, and the new local content
|
|
// has not been downloaded yet.
|
|
await checkThrowAsync(async () => await Resource.resourceBlobContent(resource.id));
|
|
|
|
// Now download resources, and our local content would have been overwritten by
|
|
// the content from client 2
|
|
await resourceFetcher().start();
|
|
await resourceFetcher().waitForAllFinished();
|
|
const localContent = await Resource.resourceBlobContent(resource.id, 'utf8');
|
|
expect(localContent).toBe('1234 MOD 2');
|
|
|
|
// Check that the Conflict note has been generated, with the conflict resource
|
|
// attached to it, and check that it has the original content.
|
|
const allNotes = await Note.all();
|
|
expect(allNotes.length).toBe(2);
|
|
const conflictNote = allNotes.find((v) => {
|
|
return !!v.is_conflict;
|
|
});
|
|
expect(!!conflictNote).toBe(true);
|
|
const resourceIds = await Note.linkedResourceIds(conflictNote.body);
|
|
expect(resourceIds.length).toBe(1);
|
|
const conflictContent = await Resource.resourceBlobContent(resourceIds[0], 'utf8');
|
|
expect(conflictContent).toBe('1234 MOD 1');
|
|
}
|
|
}));
|
|
|
|
it('should handle resource conflicts if a resource is changed locally but deleted remotely', asyncTest(async () => {
|
|
{
|
|
const tempFile = tempFilePath('txt');
|
|
await shim.fsDriver().writeFile(tempFile, '1234', 'utf8');
|
|
const folder1 = await Folder.save({ title: 'folder1' });
|
|
const note1 = await Note.save({ title: 'ma note', parent_id: folder1.id });
|
|
await shim.attachFileToNote(note1, tempFile);
|
|
await synchronizer().start();
|
|
}
|
|
|
|
await switchClient(2);
|
|
|
|
{
|
|
await synchronizer().start();
|
|
await resourceFetcher().start();
|
|
await resourceFetcher().waitForAllFinished();
|
|
}
|
|
|
|
await switchClient(1);
|
|
|
|
{
|
|
const resource = (await Resource.all())[0];
|
|
await Resource.delete(resource.id);
|
|
await synchronizer().start();
|
|
|
|
}
|
|
|
|
await switchClient(2);
|
|
|
|
{
|
|
const originalResource = (await Resource.all())[0];
|
|
await Resource.save({ id: originalResource.id, title: 'modified resource' });
|
|
await synchronizer().start(); // CONFLICT
|
|
|
|
const deletedResource = await Resource.load(originalResource.id);
|
|
expect(!deletedResource).toBe(true);
|
|
|
|
const allResources = await Resource.all();
|
|
expect(allResources.length).toBe(1);
|
|
const conflictResource = allResources[0];
|
|
expect(originalResource.id).not.toBe(conflictResource.id);
|
|
expect(conflictResource.title).toBe('modified resource');
|
|
}
|
|
}));
|
|
|
|
it('should upload decrypted items to sync target after encryption disabled', asyncTest(async () => {
|
|
Setting.setValue('encryption.enabled', true);
|
|
const masterKey = await loadEncryptionMasterKey();
|
|
|
|
const folder1 = await Folder.save({ title: 'folder1' });
|
|
await synchronizer().start();
|
|
|
|
let allEncrypted = await allSyncTargetItemsEncrypted();
|
|
expect(allEncrypted).toBe(true);
|
|
|
|
await encryptionService().disableEncryption();
|
|
|
|
await synchronizer().start();
|
|
allEncrypted = await allSyncTargetItemsEncrypted();
|
|
expect(allEncrypted).toBe(false);
|
|
}));
|
|
|
|
it('should not upload any item if encryption was enabled, and items have not been decrypted, and then encryption disabled', asyncTest(async () => {
|
|
// For some reason I can't explain, this test is sometimes executed before beforeEach is finished
|
|
// which means it's going to fail in unexpected way. So the loop below wait for beforeEach to be done.
|
|
while (insideBeforeEach) await time.msleep(100);
|
|
|
|
Setting.setValue('encryption.enabled', true);
|
|
const masterKey = await loadEncryptionMasterKey();
|
|
|
|
const folder1 = await Folder.save({ title: 'folder1' });
|
|
await synchronizer().start();
|
|
|
|
await switchClient(2);
|
|
|
|
await synchronizer().start();
|
|
expect(Setting.value('encryption.enabled')).toBe(true);
|
|
|
|
// If we try to disable encryption now, it should throw an error because some items are
|
|
// currently encrypted. They must be decrypted first so that they can be sent as
|
|
// plain text to the sync target.
|
|
// let hasThrown = await checkThrowAsync(async () => await encryptionService().disableEncryption());
|
|
// expect(hasThrown).toBe(true);
|
|
|
|
// Now supply the password, and decrypt the items
|
|
Setting.setObjectKey('encryption.passwordCache', masterKey.id, '123456');
|
|
await encryptionService().loadMasterKeysFromSettings();
|
|
await decryptionWorker().start();
|
|
|
|
// Try to disable encryption again
|
|
const hasThrown = await checkThrowAsync(async () => await encryptionService().disableEncryption());
|
|
expect(hasThrown).toBe(false);
|
|
|
|
// If we sync now the target should receive the decrypted items
|
|
await synchronizer().start();
|
|
const allEncrypted = await allSyncTargetItemsEncrypted();
|
|
expect(allEncrypted).toBe(false);
|
|
}));
|
|
|
|
it('should set the resource file size after decryption', asyncTest(async () => {
|
|
Setting.setValue('encryption.enabled', true);
|
|
const masterKey = await loadEncryptionMasterKey();
|
|
|
|
const folder1 = await Folder.save({ title: 'folder1' });
|
|
const note1 = await Note.save({ title: 'ma note', parent_id: folder1.id });
|
|
await shim.attachFileToNote(note1, `${__dirname}/../tests/support/photo.jpg`);
|
|
const resource1 = (await Resource.all())[0];
|
|
await Resource.setFileSizeOnly(resource1.id, -1);
|
|
const resourcePath1 = Resource.fullPath(resource1);
|
|
await synchronizer().start();
|
|
|
|
await switchClient(2);
|
|
|
|
await synchronizer().start();
|
|
Setting.setObjectKey('encryption.passwordCache', masterKey.id, '123456');
|
|
await encryptionService().loadMasterKeysFromSettings();
|
|
|
|
const fetcher = new ResourceFetcher(() => { return synchronizer().api(); });
|
|
fetcher.queueDownload_(resource1.id);
|
|
await fetcher.waitForAllFinished();
|
|
await decryptionWorker().start();
|
|
|
|
const resource1_2 = await Resource.load(resource1.id);
|
|
expect(resource1_2.size).toBe(2720);
|
|
}));
|
|
|
|
it('should encrypt remote resources after encryption has been enabled', asyncTest(async () => {
|
|
while (insideBeforeEach) await time.msleep(100);
|
|
|
|
const folder1 = await Folder.save({ title: 'folder1' });
|
|
const note1 = await Note.save({ title: 'ma note', parent_id: folder1.id });
|
|
await shim.attachFileToNote(note1, `${__dirname}/../tests/support/photo.jpg`);
|
|
await synchronizer().start();
|
|
|
|
expect(await allSyncTargetItemsEncrypted()).toBe(false);
|
|
|
|
const masterKey = await loadEncryptionMasterKey();
|
|
await encryptionService().enableEncryption(masterKey, '123456');
|
|
await encryptionService().loadMasterKeysFromSettings();
|
|
|
|
await synchronizer().start();
|
|
|
|
expect(await allSyncTargetItemsEncrypted()).toBe(true);
|
|
}));
|
|
|
|
it('should upload encrypted resource, but it should not mark the blob as encrypted locally', asyncTest(async () => {
|
|
while (insideBeforeEach) await time.msleep(100);
|
|
|
|
const folder1 = await Folder.save({ title: 'folder1' });
|
|
const note1 = await Note.save({ title: 'ma note', parent_id: folder1.id });
|
|
await shim.attachFileToNote(note1, `${__dirname}/../tests/support/photo.jpg`);
|
|
const masterKey = await loadEncryptionMasterKey();
|
|
await encryptionService().enableEncryption(masterKey, '123456');
|
|
await encryptionService().loadMasterKeysFromSettings();
|
|
await synchronizer().start();
|
|
|
|
const resource1 = (await Resource.all())[0];
|
|
expect(resource1.encryption_blob_encrypted).toBe(0);
|
|
}));
|
|
|
|
it('should create remote items with UTF-8 content', asyncTest(async () => {
|
|
const folder = await Folder.save({ title: 'Fahrräder' });
|
|
await Note.save({ title: 'Fahrräder', body: 'Fahrräder', parent_id: folder.id });
|
|
const all = await allNotesFolders();
|
|
|
|
await synchronizer().start();
|
|
|
|
await localNotesFoldersSameAsRemote(all, expect);
|
|
}));
|
|
|
|
it('should update remote items but not pull remote changes', asyncTest(async () => {
|
|
const folder = await Folder.save({ title: 'folder1' });
|
|
const note = await Note.save({ title: 'un', parent_id: folder.id });
|
|
await synchronizer().start();
|
|
|
|
await switchClient(2);
|
|
|
|
await synchronizer().start();
|
|
await Note.save({ title: 'deux', parent_id: folder.id });
|
|
await synchronizer().start();
|
|
|
|
await switchClient(1);
|
|
|
|
await Note.save({ title: 'un UPDATE', id: note.id });
|
|
await synchronizer().start({ syncSteps: ['update_remote'] });
|
|
const all = await allNotesFolders();
|
|
expect(all.length).toBe(2);
|
|
|
|
await switchClient(2);
|
|
|
|
await synchronizer().start();
|
|
const note2 = await Note.load(note.id);
|
|
expect(note2.title).toBe('un UPDATE');
|
|
}));
|
|
|
|
it('should create a new Welcome notebook on each client', asyncTest(async () => {
|
|
// Create the Welcome items on two separate clients
|
|
|
|
await WelcomeUtils.createWelcomeItems();
|
|
await synchronizer().start();
|
|
|
|
await switchClient(2);
|
|
|
|
await WelcomeUtils.createWelcomeItems();
|
|
const beforeFolderCount = (await Folder.all()).length;
|
|
const beforeNoteCount = (await Note.all()).length;
|
|
expect(beforeFolderCount === 1).toBe(true);
|
|
expect(beforeNoteCount > 1).toBe(true);
|
|
|
|
await synchronizer().start();
|
|
|
|
const afterFolderCount = (await Folder.all()).length;
|
|
const afterNoteCount = (await Note.all()).length;
|
|
|
|
expect(afterFolderCount).toBe(beforeFolderCount * 2);
|
|
expect(afterNoteCount).toBe(beforeNoteCount * 2);
|
|
|
|
// Changes to the Welcome items should be synced to all clients
|
|
|
|
const f1 = (await Folder.all())[0];
|
|
await Folder.save({ id: f1.id, title: 'Welcome MOD' });
|
|
|
|
await synchronizer().start();
|
|
|
|
await switchClient(1);
|
|
|
|
await synchronizer().start();
|
|
|
|
const f1_1 = await Folder.load(f1.id);
|
|
expect(f1_1.title).toBe('Welcome MOD');
|
|
}));
|
|
|
|
it('should not save revisions when updating a note via sync', asyncTest(async () => {
|
|
// When a note is updated, a revision of the original is created.
|
|
// Here, on client 1, the note is updated for the first time, however since it is
|
|
// via sync, we don't create a revision - that revision has already been created on client
|
|
// 2 and is going to be synced.
|
|
|
|
const n1 = await Note.save({ title: 'testing' });
|
|
await synchronizer().start();
|
|
|
|
await switchClient(2);
|
|
|
|
await synchronizer().start();
|
|
await Note.save({ id: n1.id, title: 'mod from client 2' });
|
|
await revisionService().collectRevisions();
|
|
const allRevs1 = await Revision.allByType(BaseModel.TYPE_NOTE, n1.id);
|
|
expect(allRevs1.length).toBe(1);
|
|
await synchronizer().start();
|
|
|
|
await switchClient(1);
|
|
|
|
await synchronizer().start();
|
|
const allRevs2 = await Revision.allByType(BaseModel.TYPE_NOTE, n1.id);
|
|
expect(allRevs2.length).toBe(1);
|
|
expect(allRevs2[0].id).toBe(allRevs1[0].id);
|
|
}));
|
|
|
|
it('should not save revisions when deleting a note via sync', asyncTest(async () => {
|
|
const n1 = await Note.save({ title: 'testing' });
|
|
await synchronizer().start();
|
|
|
|
await switchClient(2);
|
|
|
|
await synchronizer().start();
|
|
await Note.delete(n1.id);
|
|
await revisionService().collectRevisions(); // REV 1
|
|
{
|
|
const allRevs = await Revision.allByType(BaseModel.TYPE_NOTE, n1.id);
|
|
expect(allRevs.length).toBe(1);
|
|
}
|
|
await synchronizer().start();
|
|
|
|
await switchClient(1);
|
|
|
|
await synchronizer().start(); // The local note gets deleted here, however a new rev is *not* created
|
|
{
|
|
const allRevs = await Revision.allByType(BaseModel.TYPE_NOTE, n1.id);
|
|
expect(allRevs.length).toBe(1);
|
|
}
|
|
|
|
const notes = await Note.all();
|
|
expect(notes.length).toBe(0);
|
|
}));
|
|
|
|
it('should not save revisions when an item_change has been generated as a result of a sync', asyncTest(async () => {
|
|
// When a note is modified an item_change object is going to be created. This
|
|
// is used for example to tell the search engine, when note should be indexed. It is
|
|
// also used by the revision service to tell what note should get a new revision.
|
|
// When a note is modified via sync, this item_change object is also created. The issue
|
|
// is that we don't want to create revisions for these particular item_changes, because
|
|
// such revision has already been created on another client (whatever client initially
|
|
// modified the note), and that rev is going to be synced.
|
|
//
|
|
// So in the end we need to make sure that we don't create these unecessary additional revisions.
|
|
|
|
const n1 = await Note.save({ title: 'testing' });
|
|
await synchronizer().start();
|
|
|
|
await switchClient(2);
|
|
|
|
await synchronizer().start();
|
|
await Note.save({ id: n1.id, title: 'mod from client 2' });
|
|
await revisionService().collectRevisions();
|
|
await synchronizer().start();
|
|
|
|
await switchClient(1);
|
|
|
|
await synchronizer().start();
|
|
|
|
{
|
|
const allRevs = await Revision.allByType(BaseModel.TYPE_NOTE, n1.id);
|
|
expect(allRevs.length).toBe(1);
|
|
}
|
|
|
|
await revisionService().collectRevisions();
|
|
|
|
{
|
|
const allRevs = await Revision.allByType(BaseModel.TYPE_NOTE, n1.id);
|
|
expect(allRevs.length).toBe(1);
|
|
}
|
|
}));
|
|
|
|
it('should handle case when new rev is created on client, then older rev arrives later via sync', asyncTest(async () => {
|
|
// - C1 creates note 1
|
|
// - C1 modifies note 1 - REV1 created
|
|
// - C1 sync
|
|
// - C2 sync
|
|
// - C2 receives note 1
|
|
// - C2 modifies note 1 - REV2 created (but not based on REV1)
|
|
// - C2 receives REV1
|
|
//
|
|
// In that case, we need to make sure that REV1 and REV2 are both valid and can be retrieved.
|
|
// Even though REV1 was created before REV2, REV2 is *not* based on REV1. This is not ideal
|
|
// due to unecessary data being saved, but a possible edge case and we simply need to check
|
|
// all the data is valid.
|
|
|
|
const n1 = await Note.save({ title: 'note' });
|
|
await Note.save({ id: n1.id, title: 'note REV1' });
|
|
await revisionService().collectRevisions(); // REV1
|
|
expect((await Revision.allByType(BaseModel.TYPE_NOTE, n1.id)).length).toBe(1);
|
|
await synchronizer().start();
|
|
|
|
await switchClient(2);
|
|
|
|
synchronizer().testingHooks_ = ['skipRevisions'];
|
|
await synchronizer().start();
|
|
synchronizer().testingHooks_ = [];
|
|
|
|
await Note.save({ id: n1.id, title: 'note REV2' });
|
|
await revisionService().collectRevisions(); // REV2
|
|
expect((await Revision.allByType(BaseModel.TYPE_NOTE, n1.id)).length).toBe(1);
|
|
await synchronizer().start(); // Sync the rev that had been skipped above with skipRevisions
|
|
|
|
const revisions = await Revision.allByType(BaseModel.TYPE_NOTE, n1.id);
|
|
expect(revisions.length).toBe(2);
|
|
|
|
expect((await revisionService().revisionNote(revisions, 0)).title).toBe('note REV1');
|
|
expect((await revisionService().revisionNote(revisions, 1)).title).toBe('note REV2');
|
|
}));
|
|
|
|
it('should not download resources over the limit', asyncTest(async () => {
|
|
const note1 = await Note.save({ title: 'note' });
|
|
await shim.attachFileToNote(note1, `${__dirname}/../tests/support/photo.jpg`);
|
|
await synchronizer().start();
|
|
|
|
await switchClient(2);
|
|
|
|
const previousMax = synchronizer().maxResourceSize_;
|
|
synchronizer().maxResourceSize_ = 1;
|
|
await synchronizer().start();
|
|
synchronizer().maxResourceSize_ = previousMax;
|
|
|
|
const syncItems = await BaseItem.allSyncItems(syncTargetId());
|
|
expect(syncItems.length).toBe(2);
|
|
expect(syncItems[1].item_location).toBe(BaseItem.SYNC_ITEM_LOCATION_REMOTE);
|
|
expect(syncItems[1].sync_disabled).toBe(1);
|
|
}));
|
|
|
|
it('should not upload a resource if it has not been fetched yet', asyncTest(async () => {
|
|
// In some rare cases, the synchronizer might try to upload a resource even though it
|
|
// doesn't have the resource file. It can happen in this situation:
|
|
// - C1 create resource
|
|
// - C1 sync
|
|
// - C2 sync
|
|
// - C2 resource metadata is received but ResourceFetcher hasn't downloaded the file yet
|
|
// - C2 enables E2EE - all the items are marked for forced sync
|
|
// - C2 sync
|
|
// The synchronizer will try to upload the resource, even though it doesn't have the file,
|
|
// so we need to make sure it doesn't. But also that once it gets the file, the resource
|
|
// does get uploaded.
|
|
|
|
const note1 = await Note.save({ title: 'note' });
|
|
await shim.attachFileToNote(note1, `${__dirname}/../tests/support/photo.jpg`);
|
|
const resource = (await Resource.all())[0];
|
|
await Resource.setLocalState(resource.id, { fetch_status: Resource.FETCH_STATUS_IDLE });
|
|
await synchronizer().start();
|
|
|
|
expect((await remoteResources()).length).toBe(0);
|
|
|
|
await Resource.setLocalState(resource.id, { fetch_status: Resource.FETCH_STATUS_DONE });
|
|
await synchronizer().start();
|
|
|
|
expect((await remoteResources()).length).toBe(1);
|
|
}));
|
|
|
|
it('should decrypt the resource metadata, but not try to decrypt the file, if it is not present', asyncTest(async () => {
|
|
const note1 = await Note.save({ title: 'note' });
|
|
await shim.attachFileToNote(note1, `${__dirname}/../tests/support/photo.jpg`);
|
|
const masterKey = await loadEncryptionMasterKey();
|
|
await encryptionService().enableEncryption(masterKey, '123456');
|
|
await encryptionService().loadMasterKeysFromSettings();
|
|
await synchronizer().start();
|
|
expect(await allSyncTargetItemsEncrypted()).toBe(true);
|
|
|
|
await switchClient(2);
|
|
|
|
await synchronizer().start();
|
|
Setting.setObjectKey('encryption.passwordCache', masterKey.id, '123456');
|
|
await encryptionService().loadMasterKeysFromSettings();
|
|
await decryptionWorker().start();
|
|
|
|
let resource = (await Resource.all())[0];
|
|
|
|
expect(!!resource.encryption_applied).toBe(false);
|
|
expect(!!resource.encryption_blob_encrypted).toBe(true);
|
|
|
|
const resourceFetcher = new ResourceFetcher(() => { return synchronizer().api(); });
|
|
await resourceFetcher.start();
|
|
await resourceFetcher.waitForAllFinished();
|
|
|
|
const ls = await Resource.localState(resource);
|
|
expect(ls.fetch_status).toBe(Resource.FETCH_STATUS_DONE);
|
|
|
|
await decryptionWorker().start();
|
|
resource = (await Resource.all())[0];
|
|
|
|
expect(!!resource.encryption_blob_encrypted).toBe(false);
|
|
}));
|
|
|
|
it('should not create revisions when item is modified as a result of decryption', asyncTest(async () => {
|
|
// Handle this scenario:
|
|
// - C1 creates note
|
|
// - C1 never changes it
|
|
// - E2EE is enabled
|
|
// - C1 sync
|
|
// - More than one week later (as defined by oldNoteCutOffDate_), C2 sync
|
|
// - C2 enters master password and note gets decrypted
|
|
//
|
|
// Technically at this point the note is modified (from encrypted to non-encrypted) and thus a ItemChange
|
|
// object is created. The note is also older than oldNoteCutOffDate. However, this should not lead to the
|
|
// creation of a revision because that change was not the result of a user action.
|
|
// I guess that's the general rule - changes that come from user actions should result in revisions,
|
|
// while automated changes (sync, decryption) should not.
|
|
|
|
const dateInPast = revisionService().oldNoteCutOffDate_() - 1000;
|
|
|
|
await Note.save({ title: 'ma note', updated_time: dateInPast, created_time: dateInPast }, { autoTimestamp: false });
|
|
const masterKey = await loadEncryptionMasterKey();
|
|
await encryptionService().enableEncryption(masterKey, '123456');
|
|
await encryptionService().loadMasterKeysFromSettings();
|
|
await synchronizer().start();
|
|
|
|
await switchClient(2);
|
|
|
|
await synchronizer().start();
|
|
|
|
Setting.setObjectKey('encryption.passwordCache', masterKey.id, '123456');
|
|
await encryptionService().loadMasterKeysFromSettings();
|
|
await decryptionWorker().start();
|
|
|
|
await revisionService().collectRevisions();
|
|
|
|
expect((await Revision.all()).length).toBe(0);
|
|
}));
|
|
|
|
it('should stop trying to decrypt item after a few attempts', asyncTest(async () => {
|
|
let hasThrown;
|
|
|
|
const note = await Note.save({ title: 'ma note' });
|
|
const masterKey = await loadEncryptionMasterKey();
|
|
await encryptionService().enableEncryption(masterKey, '123456');
|
|
await encryptionService().loadMasterKeysFromSettings();
|
|
await synchronizer().start();
|
|
|
|
await switchClient(2);
|
|
|
|
await synchronizer().start();
|
|
|
|
// First, simulate a broken note and check that the decryption worker
|
|
// gives up decrypting after a number of tries. This is mainly relevant
|
|
// for data that crashes the mobile application - we don't want to keep
|
|
// decrypting these.
|
|
|
|
const encryptedNote = await Note.load(note.id);
|
|
const goodCipherText = encryptedNote.encryption_cipher_text;
|
|
await Note.save({ id: note.id, encryption_cipher_text: 'doesntlookright' });
|
|
|
|
Setting.setObjectKey('encryption.passwordCache', masterKey.id, '123456');
|
|
await encryptionService().loadMasterKeysFromSettings();
|
|
|
|
hasThrown = await checkThrowAsync(async () => await decryptionWorker().start({ errorHandler: 'throw' }));
|
|
expect(hasThrown).toBe(true);
|
|
|
|
hasThrown = await checkThrowAsync(async () => await decryptionWorker().start({ errorHandler: 'throw' }));
|
|
expect(hasThrown).toBe(true);
|
|
|
|
// Third time, an error is logged and no error is thrown
|
|
hasThrown = await checkThrowAsync(async () => await decryptionWorker().start({ errorHandler: 'throw' }));
|
|
expect(hasThrown).toBe(false);
|
|
|
|
const disabledItems = await decryptionWorker().decryptionDisabledItems();
|
|
expect(disabledItems.length).toBe(1);
|
|
expect(disabledItems[0].id).toBe(note.id);
|
|
|
|
expect((await kvStore().all()).length).toBe(1);
|
|
await kvStore().clear();
|
|
|
|
// Now check that if it fails once but succeed the second time, the note
|
|
// is correctly decrypted and the counters are cleared.
|
|
|
|
hasThrown = await checkThrowAsync(async () => await decryptionWorker().start({ errorHandler: 'throw' }));
|
|
expect(hasThrown).toBe(true);
|
|
|
|
await Note.save({ id: note.id, encryption_cipher_text: goodCipherText });
|
|
|
|
hasThrown = await checkThrowAsync(async () => await decryptionWorker().start({ errorHandler: 'throw' }));
|
|
expect(hasThrown).toBe(false);
|
|
|
|
const decryptedNote = await Note.load(note.id);
|
|
expect(decryptedNote.title).toBe('ma note');
|
|
|
|
expect((await kvStore().all()).length).toBe(0);
|
|
expect((await decryptionWorker().decryptionDisabledItems()).length).toBe(0);
|
|
}));
|
|
|
|
it('should not wipe out user data when syncing with an empty target', asyncTest(async () => {
|
|
for (let i = 0; i < 10; i++) await Note.save({ title: 'note' });
|
|
|
|
Setting.setValue('sync.wipeOutFailSafe', true);
|
|
await synchronizer().start();
|
|
await fileApi().clearRoot(); // oops
|
|
await synchronizer().start();
|
|
expect((await Note.all()).length).toBe(10); // but since the fail-safe if on, the notes have not been deleted
|
|
|
|
Setting.setValue('sync.wipeOutFailSafe', false); // Now switch it off
|
|
await synchronizer().start();
|
|
expect((await Note.all()).length).toBe(0); // Since the fail-safe was off, the data has been cleared
|
|
|
|
// Handle case where the sync target has been wiped out, then the user creates one note and sync.
|
|
|
|
for (let i = 0; i < 10; i++) await Note.save({ title: 'note' });
|
|
Setting.setValue('sync.wipeOutFailSafe', true);
|
|
await synchronizer().start();
|
|
await fileApi().clearRoot();
|
|
await Note.save({ title: 'ma note encore' });
|
|
await synchronizer().start();
|
|
expect((await Note.all()).length).toBe(11);
|
|
}));
|
|
|
|
it('should not sync if client sync version is lower than target', asyncTest(async () => {
|
|
// This should work - syncing two clients with same supported sync target version
|
|
await synchronizer().start();
|
|
await switchClient(2);
|
|
await synchronizer().start();
|
|
|
|
// This should not work - syncing two clients, but one of them has not been upgraded yet to the latest sync version
|
|
await switchClient(1);
|
|
Setting.setConstant('syncVersion', 2);
|
|
await synchronizer().start();
|
|
|
|
await switchClient(2);
|
|
Setting.setConstant('syncVersion', 1);
|
|
const hasThrown = await checkThrowAsync(async () => synchronizer().start({ throwOnError: true }));
|
|
expect(hasThrown).toBe(true);
|
|
}));
|
|
|
|
it('should not sync when target is locked', asyncTest(async () => {
|
|
await synchronizer().start();
|
|
await synchronizer().acquireLock_();
|
|
|
|
await switchClient(2);
|
|
const hasThrown = await checkThrowAsync(async () => synchronizer().start({ throwOnError: true }));
|
|
expect(hasThrown).toBe(true);
|
|
}));
|
|
|
|
it('should clear a lock if it was created by the same app as the current one', asyncTest(async () => {
|
|
await synchronizer().start();
|
|
await synchronizer().acquireLock_();
|
|
expect((await synchronizer().lockFiles_()).length).toBe(1);
|
|
await synchronizer().start({ throwOnError: true });
|
|
expect((await synchronizer().lockFiles_()).length).toBe(0);
|
|
}));
|
|
|
|
it('should not encrypt notes that are shared', asyncTest(async () => {
|
|
Setting.setValue('encryption.enabled', true);
|
|
await loadEncryptionMasterKey();
|
|
|
|
const folder1 = await Folder.save({ title: 'folder1' });
|
|
const note1 = await Note.save({ title: 'un', parent_id: folder1.id });
|
|
let note2 = await Note.save({ title: 'deux', parent_id: folder1.id });
|
|
await synchronizer().start();
|
|
|
|
await switchClient(2);
|
|
|
|
await synchronizer().start();
|
|
|
|
await switchClient(1);
|
|
|
|
const origNote2 = Object.assign({}, note2);
|
|
await BaseItem.updateShareStatus(note2, true);
|
|
note2 = await Note.load(note2.id);
|
|
|
|
// Sharing a note should not modify the timestamps
|
|
expect(note2.user_updated_time).toBe(origNote2.user_updated_time);
|
|
expect(note2.user_created_time).toBe(origNote2.user_created_time);
|
|
|
|
await synchronizer().start();
|
|
|
|
await switchClient(2);
|
|
|
|
await synchronizer().start();
|
|
|
|
// The shared note should be decrypted
|
|
const note2_2 = await Note.load(note2.id);
|
|
expect(note2_2.title).toBe('deux');
|
|
expect(note2_2.is_shared).toBe(1);
|
|
|
|
// The non-shared note should be encrypted
|
|
const note1_2 = await Note.load(note1.id);
|
|
expect(note1_2.title).toBe('');
|
|
}));
|
|
|
|
});
|