一些检查失败了
automerge / automerge (push) Has been cancelled
react-native-android-build-apk / AssembleRelease (push) Has been cancelled
Build macOS M1 / Main (push) Has been cancelled
Joplin Continuous Integration / Main (macos-15-intel) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-11-vs2026-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-2025) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04-arm) (push) Has been cancelled
Joplin UI tests / Main (ubuntu-22.04) (push) Has been cancelled
Joplin UI tests / Main (windows-2025) (push) Has been cancelled
Joplin UI tests / Mobile (push) Has been cancelled
Close stale issues / ProcessStaleIssues (push) Has been cancelled
438 行
17 KiB
TypeScript
438 行
17 KiB
TypeScript
import Setting from '../../models/Setting';
|
|
import BaseModel from '../../BaseModel';
|
|
import { synchronizerStart, revisionService, setupDatabaseAndSynchronizer, synchronizer, switchClient, encryptionService, loadEncryptionMasterKey, decryptionWorker } from '../../testing/test-utils';
|
|
import Note from '../../models/Note';
|
|
import Revision from '../../models/Revision';
|
|
import { loadMasterKeysFromSettings, setupAndEnableEncryption } from '../e2ee/utils';
|
|
import { onRevisionServiceSettingsChanged } from './syncInfoUtils';
|
|
import EncryptionService from '../e2ee/EncryptionService';
|
|
import NoteLockKey from '../noteLock/NoteLockKey';
|
|
import NoteLockSession from '../noteLock/NoteLockSession';
|
|
import ItemChange from '../../models/ItemChange';
|
|
import { ModelType } from '../../BaseModel';
|
|
import RevisionService from '../RevisionService';
|
|
|
|
describe('Synchronizer.revisions', () => {
|
|
|
|
beforeEach(async () => {
|
|
await setupDatabaseAndSynchronizer(1);
|
|
await setupDatabaseAndSynchronizer(2);
|
|
await switchClient(1);
|
|
});
|
|
|
|
it('should not save revisions when updating a note via sync', (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 synchronizerStart();
|
|
|
|
await switchClient(2);
|
|
|
|
await synchronizerStart();
|
|
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 synchronizerStart();
|
|
|
|
await switchClient(1);
|
|
|
|
await synchronizerStart();
|
|
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', (async () => {
|
|
const n1 = await Note.save({ title: 'testing' });
|
|
await synchronizerStart();
|
|
|
|
await switchClient(2);
|
|
|
|
await synchronizerStart();
|
|
await Note.delete(n1.id);
|
|
await revisionService().collectRevisions(); // One revision is created here because it is an "old note", see revisionService
|
|
{
|
|
const allRevs = await Revision.allByType(BaseModel.TYPE_NOTE, n1.id);
|
|
expect(allRevs.length).toBe(1);
|
|
}
|
|
await synchronizerStart();
|
|
|
|
await switchClient(1);
|
|
|
|
await synchronizerStart(); // 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(0);
|
|
}
|
|
|
|
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', (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 unnecessary additional revisions.
|
|
|
|
const n1 = await Note.save({ title: 'testing' });
|
|
await synchronizerStart();
|
|
|
|
await switchClient(2);
|
|
|
|
await synchronizerStart();
|
|
await Note.save({ id: n1.id, title: 'mod from client 2' });
|
|
await revisionService().collectRevisions();
|
|
await synchronizerStart();
|
|
|
|
await switchClient(1);
|
|
|
|
await synchronizerStart();
|
|
|
|
{
|
|
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', (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 unnecessary data being saved, but a possible edge case and we simply need to check
|
|
// all the data is valid.
|
|
|
|
// Note: this test seems to be a bit shaky because it doesn't work if the synchronizer
|
|
// context is passed around (via synchronizerStart()), but it should.
|
|
|
|
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 create revisions when item is modified as a result of decryption', (async () => {
|
|
// This test does a full encrypt/sync/decrypt round-trip and is prone to timing out
|
|
// under CI runner contention, so allow one retry.
|
|
jest.retryTimes(2, { logErrorsBeforeRetry: true });
|
|
|
|
// 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 setupAndEnableEncryption(encryptionService(), masterKey, '123456');
|
|
await loadMasterKeysFromSettings(encryptionService());
|
|
await synchronizerStart();
|
|
|
|
await switchClient(2);
|
|
|
|
await synchronizerStart();
|
|
|
|
Setting.setObjectValue('encryption.passwordCache', masterKey.id, '123456');
|
|
await loadMasterKeysFromSettings(encryptionService());
|
|
await decryptionWorker().start();
|
|
|
|
await revisionService().collectRevisions();
|
|
|
|
expect((await Revision.all()).length).toBe(0);
|
|
}));
|
|
|
|
it('should delete old revisions remotely when deleted locally', async () => {
|
|
Setting.setValue('revisionService.intervalBetweenRevisions', 100);
|
|
jest.useFakeTimers({ advanceTimers: true });
|
|
|
|
const note = await Note.save({ title: 'note' });
|
|
const getNoteRevisions = () => {
|
|
return Revision.allByType(BaseModel.TYPE_NOTE, note.id);
|
|
};
|
|
jest.advanceTimersByTime(200);
|
|
|
|
await Note.save({ id: note.id, title: 'note REV0' });
|
|
jest.advanceTimersByTime(200);
|
|
|
|
await revisionService().collectRevisions(); // REV0
|
|
expect(await getNoteRevisions()).toHaveLength(1);
|
|
|
|
jest.advanceTimersByTime(200);
|
|
|
|
await Note.save({ id: note.id, title: 'note REV1' });
|
|
await revisionService().collectRevisions(); // REV1
|
|
expect(await getNoteRevisions()).toHaveLength(2);
|
|
|
|
// Should sync the revisions
|
|
await synchronizer().start();
|
|
await switchClient(2);
|
|
await synchronizer().start();
|
|
|
|
expect(await getNoteRevisions()).toHaveLength(2);
|
|
await revisionService().deleteOldRevisions(100);
|
|
expect(await getNoteRevisions()).toHaveLength(0);
|
|
|
|
await synchronizer().start();
|
|
expect(await getNoteRevisions()).toHaveLength(0);
|
|
|
|
// Syncing a new client should not download the deleted revisions
|
|
await setupDatabaseAndSynchronizer(3);
|
|
await switchClient(3);
|
|
await synchronizer().start();
|
|
expect(await getNoteRevisions()).toHaveLength(0);
|
|
|
|
// After switching back to the original client, syncing should locally delete
|
|
// the remotely deleted revisions.
|
|
await switchClient(1);
|
|
expect(await getNoteRevisions()).toHaveLength(2);
|
|
await synchronizer().start();
|
|
expect(await getNoteRevisions()).toHaveLength(0);
|
|
|
|
jest.useRealTimers();
|
|
});
|
|
|
|
it('should sync both deleted and merged revisions to remote, when revision deletion retains some revisions locally', async () => {
|
|
// - C1 creates note 1
|
|
// - C1 modifies note 1 over a period of time - 2 revisions are created
|
|
// - C1 sync
|
|
// - C2 sync
|
|
// - C2 receives note 1 with the revisions
|
|
// - C2 deletes the oldest of the 2 revisions, leaving 1 merged revision
|
|
// - C2 sync
|
|
// - C1 sync
|
|
// - C1 receives 1 merged revision and the older one is deleted
|
|
//
|
|
// When at least one, but not all revisions are deleted for a note, the new oldest revision must be a merge of all
|
|
// previous revisions which were deleted. So in addition to verifying that old revision deletions are synced so that
|
|
// other clients will delete those revisions, we also need to verify that a merged revision is synced and is then updated
|
|
// when another client receives it
|
|
Setting.setValue('revisionService.intervalBetweenRevisions', 100);
|
|
jest.useFakeTimers({ advanceTimers: true });
|
|
|
|
const note = await Note.save({ title: 'note' });
|
|
const getNoteRevisions = () => {
|
|
return Revision.allByType(BaseModel.TYPE_NOTE, note.id);
|
|
};
|
|
jest.advanceTimersByTime(500);
|
|
|
|
await Note.save({ id: note.id, title: 'note REV0' });
|
|
jest.advanceTimersByTime(500);
|
|
|
|
await revisionService().collectRevisions(); // REV0
|
|
expect(await getNoteRevisions()).toHaveLength(1);
|
|
|
|
const interimTime = Date.now();
|
|
jest.advanceTimersByTime(500);
|
|
|
|
await Note.save({ id: note.id, title: 'note REV1' });
|
|
await revisionService().collectRevisions(); // REV1
|
|
expect(await getNoteRevisions()).toHaveLength(2);
|
|
|
|
// Should sync the revisions
|
|
await synchronizerStart();
|
|
await switchClient(2);
|
|
await synchronizerStart();
|
|
|
|
// Prevent a race condition whereby a revision is downloaded via the sync, then one of the same revisions is updated within the same millisecond via
|
|
// deleteOldRevisions, and therefore is not uploaded via the sync because the sync_time matches
|
|
jest.advanceTimersByTime(500);
|
|
|
|
const revisions = await getNoteRevisions();
|
|
expect(revisions).toHaveLength(2);
|
|
expect(revisions[0].title_diff).toBe('[{"diffs":[[1,"note REV0"]],"start1":0,"start2":0,"length1":0,"length2":9}]');
|
|
expect(revisions[1].title_diff).toBe('[{"diffs":[[0," REV"],[-1,"0"],[1,"1"]],"start1":4,"start2":4,"length1":5,"length2":5}]');
|
|
|
|
await revisionService().deleteOldRevisions(Date.now() - interimTime);
|
|
expect(await getNoteRevisions()).toHaveLength(1);
|
|
|
|
await synchronizerStart();
|
|
expect(await getNoteRevisions()).toHaveLength(1);
|
|
|
|
// After switching back to the original client, syncing should locally delete
|
|
// the remotely deleted revisions and update the merged revision.
|
|
await switchClient(1);
|
|
expect(await getNoteRevisions()).toHaveLength(2);
|
|
await synchronizerStart();
|
|
|
|
const revisionsAfterSync = await getNoteRevisions();
|
|
expect(revisionsAfterSync).toHaveLength(1);
|
|
expect(revisionsAfterSync[0].title_diff).toBe('[{"diffs":[[1,"note REV1"]],"start1":0,"start2":0,"length1":0,"length2":9}]');
|
|
expect(revisionsAfterSync[0].updated_time).toBeGreaterThan(revisions[0].updated_time);
|
|
|
|
jest.useRealTimers();
|
|
});
|
|
|
|
it('should sync revision service settings across clients', (async () => {
|
|
const changeSetting = (key: string, value: unknown) => {
|
|
Setting.setValue(key, value);
|
|
onRevisionServiceSettingsChanged(key, value);
|
|
};
|
|
|
|
changeSetting('revisionService.enabled', false);
|
|
await synchronizerStart();
|
|
await switchClient(2);
|
|
|
|
expect(Setting.value('revisionService.enabled')).toBe(true);
|
|
await synchronizerStart();
|
|
expect(Setting.value('revisionService.enabled')).toBe(false);
|
|
|
|
changeSetting('revisionService.enabled', true);
|
|
await synchronizerStart();
|
|
await switchClient(1);
|
|
|
|
expect(Setting.value('revisionService.enabled')).toBe(false);
|
|
await synchronizerStart();
|
|
expect(Setting.value('revisionService.enabled')).toBe(true);
|
|
}));
|
|
|
|
it('should delete local plaintext revisions when a note arrives locked via sync', (async () => {
|
|
Setting.setValue('featureFlag.noteLock', true);
|
|
const note = await Note.save({ title: 'note', body: 'secret v1' });
|
|
await Note.save({ id: note.id, body: 'secret v2' });
|
|
await synchronizerStart();
|
|
// The plaintext revision only exists on this client: it is collected after the sync.
|
|
await revisionService().collectRevisions();
|
|
expect((await Revision.allByType(BaseModel.TYPE_NOTE, note.id)).some(r => !r.is_locked)).toBe(true);
|
|
|
|
await switchClient(2);
|
|
|
|
Setting.setValue('featureFlag.noteLock', true);
|
|
NoteLockSession.destroyInstance();
|
|
NoteLockKey.destroyInstance();
|
|
EncryptionService.instance_ = encryptionService();
|
|
await synchronizerStart();
|
|
await NoteLockKey.instance().create('123456');
|
|
await NoteLockSession.instance().unlock('123456');
|
|
// setNoteLockState only emits events now, so the lock transition is applied like the note screen does it.
|
|
const toSave = { ...await Note.load(note.id), is_locked: 1 };
|
|
(toSave as Record<string, unknown>).isDecrypted = true;
|
|
await Note.save(toSave, { useNoteLock: true });
|
|
await synchronizerStart();
|
|
|
|
await switchClient(1);
|
|
|
|
Setting.setValue('featureFlag.noteLock', true);
|
|
await synchronizerStart();
|
|
|
|
for (const rev of await Revision.allByType(BaseModel.TYPE_NOTE, note.id)) {
|
|
expect(rev.is_locked).toBe(1);
|
|
}
|
|
// The cached pre-change body is cleared too, otherwise the next collection would build a
|
|
// plaintext revision from it.
|
|
const itemChange = await ItemChange.itemChange(ModelType.Note, note.id);
|
|
expect(itemChange?.before_change_item ?? '').not.toContain('secret');
|
|
}));
|
|
|
|
it('should advance the editor reload marker before the incoming lock revision cleanup', (async () => {
|
|
Setting.setValue('featureFlag.noteLock', true);
|
|
const note = await Note.save({ title: 'note', body: 'secret v1' });
|
|
await synchronizerStart();
|
|
|
|
await switchClient(2);
|
|
|
|
Setting.setValue('featureFlag.noteLock', true);
|
|
NoteLockSession.destroyInstance();
|
|
NoteLockKey.destroyInstance();
|
|
EncryptionService.instance_ = encryptionService();
|
|
await synchronizerStart();
|
|
await NoteLockKey.instance().create('123456');
|
|
await NoteLockSession.instance().unlock('123456');
|
|
const toSave = { ...await Note.load(note.id), is_locked: 1 };
|
|
(toSave as Record<string, unknown>).isDecrypted = true;
|
|
await Note.save(toSave, { useNoteLock: true });
|
|
await synchronizerStart();
|
|
|
|
await switchClient(1);
|
|
|
|
Setting.setValue('featureFlag.noteLock', true);
|
|
// A queued editor save only becomes stale once the reload marker advances, so the
|
|
// dispatch must fire before the awaited revision cleanup opens a race window.
|
|
const dispatch = jest.fn();
|
|
synchronizer().dispatch = dispatch;
|
|
let dispatchedBeforeCleanup = false;
|
|
const cleanupSpy = jest.spyOn(RevisionService.instance(), 'deleteUnencryptedHistoryForNote').mockImplementation(async () => {
|
|
dispatchedBeforeCleanup = dispatch.mock.calls.some(([action]) => action.type === 'EDITOR_NOTE_NEEDS_RELOAD' && action.noteId === note.id);
|
|
});
|
|
try {
|
|
await synchronizerStart();
|
|
expect(cleanupSpy).toHaveBeenCalled();
|
|
expect(dispatchedBeforeCleanup).toBe(true);
|
|
} finally {
|
|
cleanupSpy.mockRestore();
|
|
synchronizer().dispatch = () => {};
|
|
}
|
|
}));
|
|
|
|
it('should not overwrite a customised local ttlDays with another client default', (async () => {
|
|
// Regression: a client that has never customised revisionService.ttlDays
|
|
// must not push its default over another client's customised value.
|
|
const changeSetting = (key: string, value: unknown) => {
|
|
Setting.setValue(key, value);
|
|
onRevisionServiceSettingsChanged(key, value);
|
|
};
|
|
|
|
changeSetting('revisionService.ttlDays', 30);
|
|
await synchronizerStart();
|
|
|
|
await switchClient(2);
|
|
expect(Setting.value('revisionService.ttlDays')).toBe(90);
|
|
await synchronizerStart();
|
|
expect(Setting.value('revisionService.ttlDays')).toBe(30);
|
|
|
|
await switchClient(1);
|
|
await synchronizerStart();
|
|
expect(Setting.value('revisionService.ttlDays')).toBe(30);
|
|
}));
|
|
});
|