import { afterAllCleanUp, setupDatabaseAndSynchronizer, logger, switchClient, encryptionService, msleep, fileApi } from '../../testing/test-utils'; import MasterKey from '../../models/MasterKey'; import { checkIfCanSync, localSyncInfo, localSyncInfoSelector, masterKeyEnabled, mergeSyncInfos, saveLocalSyncInfo, setMasterKeyEnabled, setMasterKeyHasBeenUsed, SyncInfo, syncInfoEquals, checkSyncTargetIsValid, fetchSyncInfo, onRevisionServiceSettingsChanged, setAppMinVersion, checkNoteLockKeyConflict, checkNoteLockKeyMigrationId } from './syncInfoUtils'; import Setting from '../../models/Setting'; import BaseItem from '../../models/BaseItem'; import BaseModel from '../../models/BaseItem'; import Logger from '@joplin/utils/Logger'; import { State } from '../../reducer'; import { MasterKeyEntity } from '../e2ee/types'; import { ErrorCode } from '../../errors'; describe('syncInfoUtils', () => { it('should keep selected sync info stable while the cache is unchanged', () => { const cache = JSON.stringify({ version: 3 }); const state = { settings: { syncInfoCache: cache } } as State; const firstResult = localSyncInfoSelector(state); expect(localSyncInfoSelector({ ...state, syncStarted: true })).toBe(firstResult); expect(localSyncInfoSelector({ settings: { syncInfoCache: JSON.stringify({ version: 4 }) } } as State)).not.toBe(firstResult); }); beforeEach(async () => { await setupDatabaseAndSynchronizer(1); await switchClient(1); await fileApi().clearRoot(); }); afterAll(async () => { await afterAllCleanUp(); }); it('should enable or disable a master key', async () => { const mk1 = await MasterKey.save(await encryptionService().generateMasterKey('111111')); const mk2 = await MasterKey.save(await encryptionService().generateMasterKey('111111')); setMasterKeyEnabled(mk2.id, false); expect(masterKeyEnabled(await MasterKey.load(mk1.id))).toBe(true); expect(masterKeyEnabled(await MasterKey.load(mk2.id))).toBe(false); setMasterKeyEnabled(mk1.id, false); expect(masterKeyEnabled(await MasterKey.load(mk1.id))).toBe(false); expect(masterKeyEnabled(await MasterKey.load(mk2.id))).toBe(false); setMasterKeyEnabled(mk1.id, true); expect(masterKeyEnabled(await MasterKey.load(mk1.id))).toBe(true); expect(masterKeyEnabled(await MasterKey.load(mk2.id))).toBe(false); }); it('should not update an already-used master key', () => { const syncInfo = new SyncInfo(); syncInfo.masterKeys = [{ id: '1', content: 'content', hasBeenUsed: true, updated_time: 123, }]; const setValueSpy = jest.spyOn(Setting, 'setValue'); try { setMasterKeyHasBeenUsed(syncInfo, '1'); expect(syncInfo.masterKeys[0].updated_time).toBe(123); expect(setValueSpy).not.toHaveBeenCalledWith('syncInfoCache', expect.anything()); } finally { setValueSpy.mockRestore(); } }); it('should tell if two sync info are equal', async () => { { const syncInfo1 = new SyncInfo(); const syncInfo2 = new SyncInfo(); expect(syncInfoEquals(syncInfo1, syncInfo2)).toBe(true); } { const syncInfo1 = new SyncInfo(); syncInfo1.masterKeys = [{ id: 'id', content: 'content', }]; const syncInfo2 = new SyncInfo(); syncInfo2.masterKeys = [{ id: 'id', content: 'different', }]; expect(syncInfoEquals(syncInfo1, syncInfo2)).toBe(false); } { const syncInfo1 = new SyncInfo(); syncInfo1.masterKeys = [{ id: 'id', content: 'content', }]; const syncInfo2 = new SyncInfo(); syncInfo2.masterKeys = [{ id: 'id', content: 'content', }]; expect(syncInfoEquals(syncInfo1, syncInfo2)).toBe(true); } { // Should disregard object key order const syncInfo1 = new SyncInfo(); syncInfo1.masterKeys = [{ content: 'content', id: 'id', }]; const syncInfo2 = new SyncInfo(); syncInfo2.masterKeys = [{ id: 'id', content: 'content', }]; expect(syncInfoEquals(syncInfo1, syncInfo2)).toBe(true); } }); it('should merge sync target info and keep the highest appMinVersion', async () => { const syncInfo1 = new SyncInfo(); syncInfo1.appMinVersion = '1.0.5'; const syncInfo2 = new SyncInfo(); syncInfo2.appMinVersion = '1.0.2'; expect(mergeSyncInfos(syncInfo1, syncInfo2).appMinVersion).toBe('1.0.5'); syncInfo1.appMinVersion = '2.1.0'; syncInfo2.appMinVersion = '2.2.5'; expect(mergeSyncInfos(syncInfo1, syncInfo2).appMinVersion).toBe('2.2.5'); syncInfo1.appMinVersion = '1.0.0'; syncInfo2.appMinVersion = '1.0.0'; expect(mergeSyncInfos(syncInfo1, syncInfo2).appMinVersion).toBe('1.0.0'); // Should prefer the version from syncInfo1 if versions are otherwise equal. syncInfo1.appMinVersion = '1.00'; syncInfo2.appMinVersion = '1.0.0'; expect(mergeSyncInfos(syncInfo1, syncInfo2).appMinVersion).toBe('1.00'); syncInfo1.appMinVersion = '0.0.0'; syncInfo2.appMinVersion = '0.00'; expect(mergeSyncInfos(syncInfo1, syncInfo2).appMinVersion).toBe('0.0.0'); }); const noteLockKey = (id: string, updated_time: number): MasterKeyEntity => ({ id, content: `content${id}-${updated_time}`, updated_time }); const syncInfoWithNoteLockKey = (key: MasterKeyEntity, syncMigrationId: string) => { const syncInfo = new SyncInfo(); syncInfo.noteLockKey = key; syncInfo.syncMigrationId = syncMigrationId; return syncInfo; }; test.each([ ['the newest copy of the same key', noteLockKey('1', 100), 'L1', noteLockKey('1', 200), 'L1', false, 'remote'], ['the local key when a password reset in the same lineage propagates', noteLockKey('2', 100), 'L1', noteLockKey('1', 200), 'L1', true, 'local'], ['the remote key in the same lineage without a propagating reset', noteLockKey('2', 300), 'L1', noteLockKey('1', 200), 'L1', false, 'remote'], ['the remote key from a different lineage even with a propagating reset', noteLockKey('2', 300), 'L2', noteLockKey('1', 200), 'L1', true, 'remote'], ['the only key, when it is remote', null, '', noteLockKey('1', 200), 'L1', false, 'remote'], ['the only key, when it is local', noteLockKey('1', 200), 'L1', null, '', false, 'local'], ])('should merge sync target info and keep %s', (_description, localKey, localLineage, remoteKey, remoteLineage, resetPropagates, winner) => { const local = syncInfoWithNoteLockKey(localKey, localLineage); const remote = syncInfoWithNoteLockKey(remoteKey, remoteLineage); const expected = winner === 'local' ? local : remote; const merged = mergeSyncInfos(local, remote, { resetPropagates }); expect(merged.noteLockKey).toEqual(expected.noteLockKey); expect(merged.syncMigrationId).toBe(expected.syncMigrationId); }); it('should refuse to merge a different lineage while local notes depend on the local key', () => { const local = syncInfoWithNoteLockKey(noteLockKey('2', 300), 'L2'); const remote = syncInfoWithNoteLockKey(noteLockKey('1', 200), 'L1'); expect(() => mergeSyncInfos(local, remote, { hasLocalLockedNotes: true })).toThrow('different lineage'); }); it('should stop the sync and park the remote key when lineages differ and local notes are locked', () => { const local = syncInfoWithNoteLockKey(noteLockKey('2', 300), 'L2'); const remote = syncInfoWithNoteLockKey(noteLockKey('1', 200), 'L1'); expect(() => checkNoteLockKeyConflict(local, remote, true, false)).toThrow(expect.objectContaining({ code: ErrorCode.NoteLockKeyConflict })); expect(Setting.value('noteLock.conflictNoteLockKey')).toEqual({ noteLockKey: remote.noteLockKey, syncMigrationId: 'L1' }); }); test.each([ ['lineages differ but no local note is locked', 'L2', false], ['the keys share a lineage', 'L1', true], ])('should let the sync continue when %s', (_description, localLineage, hasLocalLockedNotes) => { const local = syncInfoWithNoteLockKey(noteLockKey('2', 300), localLineage); const remote = syncInfoWithNoteLockKey(noteLockKey('1', 200), 'L1'); expect(() => checkNoteLockKeyConflict(local, remote, hasLocalLockedNotes, false)).not.toThrow(); expect(Setting.value('noteLock.conflictNoteLockKey')).toEqual({}); }); it('should keep rejecting syncs while notes depend on the parked key', () => { const parked = { noteLockKey: noteLockKey('1', 200), syncMigrationId: 'L1' }; Setting.setValue('noteLock.conflictNoteLockKey', parked); const local = syncInfoWithNoteLockKey(noteLockKey('3', 300), 'L3'); const remote = syncInfoWithNoteLockKey(noteLockKey('3', 300), 'L3'); expect(() => checkNoteLockKeyConflict(local, remote, false, false, true)).toThrow(expect.objectContaining({ code: ErrorCode.NoteLockKeyConflict })); expect(Setting.value('noteLock.conflictNoteLockKey')).toEqual(parked); }); test.each([ ['the sync target has no key', null], ['the sync target has the local key', noteLockKey('3', 300)], ])('should drop a parked key that no note depends on when %s', (_description, remoteKey) => { Setting.setValue('noteLock.conflictNoteLockKey', { noteLockKey: noteLockKey('1', 200), syncMigrationId: 'L1' }); const local = syncInfoWithNoteLockKey(noteLockKey('3', 300), 'L3'); const remote = syncInfoWithNoteLockKey(remoteKey, remoteKey ? 'L3' : ''); expect(() => checkNoteLockKeyConflict(local, remote, true, false)).not.toThrow(); expect(Setting.value('noteLock.conflictNoteLockKey')).toEqual({}); }); it('should park the new sync target key when it changed and no note depends on the old one', () => { Setting.setValue('noteLock.conflictNoteLockKey', { noteLockKey: noteLockKey('1', 200), syncMigrationId: 'L1' }); const local = syncInfoWithNoteLockKey(noteLockKey('3', 300), 'L3'); const remote = syncInfoWithNoteLockKey(noteLockKey('4', 400), 'L4'); expect(() => checkNoteLockKeyConflict(local, remote, true, false)).toThrow(expect.objectContaining({ code: ErrorCode.NoteLockKeyConflict })); expect(Setting.value('noteLock.conflictNoteLockKey')).toEqual({ noteLockKey: remote.noteLockKey, syncMigrationId: 'L4' }); }); test.each([ ['a key without a sync migration id', noteLockKey('1', 200), '', true], ['a key with a sync migration id', noteLockKey('1', 200), 'L1', false], ['no key', null, '', false], ])('should check the sync migration id: %s', (_description, key, syncMigrationId, throws) => { const syncInfo = syncInfoWithNoteLockKey(key, syncMigrationId); for (const [isRemote, side] of [[false, 'on this device'], [true, 'on the sync target']] as const) { const check = () => checkNoteLockKeyMigrationId(syncInfo, isRemote); if (throws) { expect(check).toThrow(side); } else { expect(check).not.toThrow(); } } }); it('should merge sync target info and takes into account usage of master key - 1', async () => { const syncInfo1 = new SyncInfo(); syncInfo1.masterKeys = [{ id: '1', content: 'content1', hasBeenUsed: true, }]; syncInfo1.activeMasterKeyId = '1'; await msleep(1); const syncInfo2 = new SyncInfo(); syncInfo2.masterKeys = [{ id: '2', content: 'content2', hasBeenUsed: false, }]; syncInfo2.activeMasterKeyId = '2'; // If one master key has been used and the other not, it should select // the one that's been used regardless of timestamps. expect(mergeSyncInfos(syncInfo1, syncInfo2).activeMasterKeyId).toBe('1'); // If both master keys have been used it should rely on timestamp // (latest modified is picked). syncInfo2.masterKeys[0].hasBeenUsed = true; expect(mergeSyncInfos(syncInfo1, syncInfo2).activeMasterKeyId).toBe('2'); }); it('should merge sync target info, but should not make a disabled key the active one', async () => { const syncInfo1 = new SyncInfo(); syncInfo1.masterKeys = [{ id: '1', content: 'content1', hasBeenUsed: true, enabled: 0, }]; syncInfo1.activeMasterKeyId = '1'; await msleep(1); const syncInfo2 = new SyncInfo(); syncInfo2.masterKeys = [{ id: '2', content: 'content2', enabled: 1, hasBeenUsed: false, }]; syncInfo2.activeMasterKeyId = '2'; // Normally, if one master key has been used (1) and the other not (2), // it should select the one that's been used regardless of timestamps. // **However**, if the key 1 has been disabled by user, it should // **not** be picked as the active one. Instead it should use key 2, // because it's still enabled. expect(mergeSyncInfos(syncInfo1, syncInfo2).activeMasterKeyId).toBe('2'); // If both key are disabled, we go back to the original logic, where we // select the key that's been used. syncInfo2.masterKeys[0].enabled = 0; expect(mergeSyncInfos(syncInfo1, syncInfo2).activeMasterKeyId).toBe('1'); }); it('should fix the sync info if it contains invalid data', async () => { logger.enabled = false; const syncInfo = new SyncInfo(); syncInfo.masterKeys = [{ id: '1', content: 'content1', hasBeenUsed: true, enabled: 0, }]; syncInfo.activeMasterKeyId = '2'; saveLocalSyncInfo(syncInfo); const loaded = localSyncInfo(); expect(loaded.activeMasterKeyId).toBe(''); expect(loaded.masterKeys.length).toBe(1); logger.enabled = true; }); // cSpell:disable it('should filter unnecessary sync info', async () => { const initialData = { 'version': 3, 'e2ee': { 'value': true, 'updatedTime': 0, }, 'activeMasterKeyId': { 'value': '400227d2222c4d3bb7346514861c643b', 'updatedTime': 0, }, 'masterKeys': [ { 'id': '400227d8a77c4d3bb7346514861c643b', 'created_time': 1515008161362, 'updated_time': 1708103706234, 'source_application': 'net.cozic.joplin-desktop', 'encryption_method': 4, 'checksum': '', 'content': '{"iv":"M1uezlW1Pu1g3dwrCTqcHg==","v":1,"iter":10000,"ks":256,"ts":64,"mode":"ccm","adata":"","cipher":"aes","salt":"0dqWvU/PUVQ=","ct":"wHXN5pk1s7qKX+2Y9puEGZGkojI1Pvc+TvZUKC6QCfwxtMK6C1Hmgvm53vAaeCMcCXPvGVLo9JwqINFhEgb0ux+KUFcCqgT1pNO2Sf/hJsH8PjaUvl0kwpC511zdnvY7Hk3WIpgXVKUevsQt9TkMK5e8y1JMsuuTD3fW7bEiv/ehe4CBSQ9eH1tWjr1qQ=="}', 'hasBeenUsed': true, }, ], 'noteLockKey': { 'id': '400227d8a77c4d3bb7346514861c643c', 'created_time': 1515008161362, 'updated_time': 1708103706234, 'source_application': 'net.cozic.joplin-desktop', 'encryption_method': 4, 'checksum': '', 'content': '{"iv":"M1uezlW1Pu1g3dwrCTqcHg=="}', }, 'ppk': { 'value': { 'id': 'SNQ5ZCs61KDVUW2qqqqHd3', 'keySize': 2048, 'privateKey': { 'encryptionMethod': 4, 'ciphertext': '{"iv":"Z2y11b4nCYvpmQ9gvxELug==","v":1,"iter":10000,"ks":256,"ts":64,"mode":"ccm","adata":"","cipher":"aes","salt":"0dqWvU/PUVQ=","ct":"8CvjYayXMpLsrAMwtu18liRfewKfZVpRlC0D0I2FYziyFhRf4Cjqi2+Uy8kIC8au7oBSBUnNU6jd04ooNozneKv2MzkhbGlXo3izxqCMVHboqa2vkPWbBAxGlvUYQUg213xG61FjZ19ZJdpti+AQy7qpQU7/h5kyC0iJ2aXG5TIGcBuDq3lbGAVfG/RlL/abMKLYb8KouFYAJe+0bUajUXa1KJsey+eD+2hFVc+nAxKOLe1UoZysB77Lq43DRTBFGH2gTSC1zOXxuZeSbFPPN0Cj+FvV7D5pF9LhUSLPDsIiRwF/q+E506YgDjirSZAvW1Y2EEM22F2Mh0I0pbVPFXhhBafqPLRwXmUGULCnA64gkGwctK5mEs985VVSrpQ0nMvf/drg2vUQrJ3exgl43ddVSOCjeJuF7F06IBL5FQ34iAujsOheRNvlWtG9xm008Vc19NxvhtzIl1RO7XLXrrTBzbFHDrcHjda/xNWNEKwU/LZrH0xPgwEcwBmLItvy/NojI/JKNeck8R431QWooFb7cTplO4qsgCQNL9MJ9avpmNSXJAUQx8VnifKVbzcY4T7X7TmJWSrpvBWV8MLfi3TOF4kahR75vg47kCrMbthFMw5bvrjvMmGOtyKxheqbS5IlSnSSz5x7wIVz0g3vzMbcbb5rF5MuzNhU97wNiz3L1Aonjmnu8r3vCyXTB/4GSiwYH7KfixwYM68T4crqJ0VneNy+owznCdJQXnG4cmjxek1wmJMEmurQ1JtANNv/m43gzoqd62V6Dq05vLJF+n7CS9HgJ3FTqYVCZLGGYrSilIYnEjhdaBpkcnFrCitbfYj+IpNC6eN6qg2hpGAbmKId7RLOGwJyda0jkuNP9mTqWOF+6eYn8Q+Y3YIY"}', }, 'publicKey': '-----BEGIN RSA PUBLIC KEY-----\nMIIBCgKCAQEAiSTY5wBscae/WmU3PfVP5FYQiuTi5V7BjPcge/6pXvgF3zwe43uy\nTWdzO2YgK/a8f3H507clcGlZN4e0e1jZ/rh4lMfaN\nugfNo0RAvuwn8Yniqfb69reygJywbFBIauxbBpVKbc21MLuCbPkVFjKG7qGNYdF4\nc17mQ8nQsbFPZcuvxsZvgvvbza1q0rqVETdDUClyIrY8plAjMgTKCRwq2gafP6eX\nWpkENAyIbOFxSKXjWy0yFidvZfYLz4mIRwIDAQAB\n-----END RSA PUBLIC KEY-----', 'createdTime': 1633274368892, }, 'updatedTime': 1633274368892, }, 'appMinVersion': '0.0.0', }; const syncInfo = new SyncInfo(); syncInfo.load(JSON.stringify(initialData)); const filteredSyncInfo = syncInfo.filterSyncInfo(); expect(filteredSyncInfo).toEqual({ 'activeMasterKeyId': { 'updatedTime': 0, 'value': '400227d2222c4d3bb7346514861c643b', }, 'appMinVersion': '0.0.0', 'syncMigrationId': '', 'e2ee': { 'updatedTime': 0, 'value': true, }, 'masterKeys': [ { 'created_time': 1515008161362, 'encryption_method': 4, 'hasBeenUsed': true, 'id': '400227d8a77c4d3bb7346514861c643b', 'source_application': 'net.cozic.joplin-desktop', 'updated_time': 1708103706234, }, ], 'noteLockKey': { 'created_time': 1515008161362, 'encryption_method': 4, 'id': '400227d8a77c4d3bb7346514861c643c', 'source_application': 'net.cozic.joplin-desktop', 'updated_time': 1708103706234, }, 'ppk': { 'updatedTime': 1633274368892, 'value': { 'createdTime': 1633274368892, 'id': 'SNQ5ZCs61KDVUW2qqqqHd3', 'keySize': 2048, 'privateKey': { 'ciphertext': '{"iv":"Z2y11b4nCYvpm...TqWOF+6eYn8Q+Y3YIY"}', 'encryptionMethod': 4, }, 'publicKey': '-----BEGIN RSA PUBLIC KEY-----\nMIIBCgKCA...', }, }, 'revisionServiceEnabled': { 'updatedTime': 0, 'value': true, }, 'revisionServiceTtlDays': { 'updatedTime': 0, 'value': 90, }, 'version': 3, }); }); // cSpell:enable test.each([ ['1.0.0', '1.0.4', true], ['1.0.0', '0.0.5', false], ['1.0.0', '1.0.0', true], ['3.7.0', '3.6.4', true], ['3.7.1', '3.6.4', false], ])('should check if it can sync', async (appMinVersion, appVersion, expected) => { let succeeded = true; try { const s = new SyncInfo(); s.appMinVersion = appMinVersion; checkIfCanSync(s, appVersion); } catch (error) { succeeded = false; } expect(succeeded).toBe(expected); }); test('should update appMinVersion in the sync info cache with a new minimum version', () => { const { reset } = setAppMinVersion('3.5.0'); try { Setting.setValue('syncInfoCache', JSON.stringify({ appMinVersion: '3.0.0' })); expect(localSyncInfo()).toMatchObject({ appMinVersion: '3.5.0' }); } finally { reset(); } }); test('should preserve a v3.7.0 version stored in the sync info cache', () => { Setting.setValue('syncInfoCache', JSON.stringify({ appMinVersion: '3.7.0' })); expect(localSyncInfo()).toMatchObject({ appMinVersion: '3.7.0' }); }); test('should not throw if the sync info being parsed is invalid', async () => { Logger.globalLogger.enabled = false; Setting.setValue('syncInfoCache', 'invalid-json'); expect(() => localSyncInfo()).not.toThrow(); Logger.globalLogger.enabled = true; }); test('should use default value if the sync info being parsed is invalid', async () => { Logger.globalLogger.enabled = false; Setting.setValue('syncInfoCache', 'invalid-json'); const result = localSyncInfo(); expect(result.activeMasterKeyId).toEqual(''); expect(result.version).toEqual(0); expect(result.ppk).toEqual(null); expect(result.e2ee).toEqual(false); expect(result.appMinVersion).toEqual('3.7.0'); expect(result.masterKeys).toEqual([]); expect(result.noteLockKey).toEqual(null); Logger.globalLogger.enabled = true; }); it('should succeed when info.json exists for checkSyncTargetIsValid', (async () => { Setting.setValue('sync.wipeOutFailSafe', true); const syncInfo = new SyncInfo(); await fileApi().put('info.json', syncInfo.serialize()); await expect(checkSyncTargetIsValid(fileApi())).resolves.not.toThrow(); })); it('should succeed when info.json does not exist and failsafe is disabled for checkSyncTargetIsValid', (async () => { Setting.setValue('sync.wipeOutFailSafe', false); await expect(checkSyncTargetIsValid(fileApi())).resolves.not.toThrow(); })); it('should fail with failsafe error when info.json does not exist for checkSyncTargetIsValid', (async () => { Setting.setValue('sync.wipeOutFailSafe', true); await expect(checkSyncTargetIsValid(fileApi())).rejects.toThrow('Fail-safe: '); })); it('should succeed when info.json exists and is valid for fetchSyncInfo', (async () => { Setting.setValue('sync.wipeOutFailSafe', true); const expectedSyncInfo = new SyncInfo(); expectedSyncInfo.version = 50; await fileApi().put('info.json', expectedSyncInfo.serialize()); const actualSyncInfo = await fetchSyncInfo(fileApi()); expect(actualSyncInfo).toStrictEqual(expectedSyncInfo); })); it('should fail with missing version error when info.json exists but is invalid for fetchSyncInfo', (async () => { Setting.setValue('sync.wipeOutFailSafe', true); await fileApi().put('info.json', new SyncInfo().serialize()); await expect(fetchSyncInfo(fileApi())).rejects.toThrow('Missing "version" field'); })); it('should succeed when info.json does not exist but .sync/version.txt does exist for fetchSyncInfo', (async () => { Setting.setValue('sync.wipeOutFailSafe', true); await fileApi().put('.sync/version.txt', '{}'); const actualSyncInfo = await fetchSyncInfo(fileApi()); expect(actualSyncInfo.version).toBe(1); })); it('should succeed when info.json and .sync/version.txt does not exist and failsafe is disabled for fetchSyncInfo', (async () => { Setting.setValue('sync.wipeOutFailSafe', false); const actualSyncInfo = await fetchSyncInfo(fileApi()); expect(actualSyncInfo.version).toBe(0); })); it('should fail with failsafe error when info.json and .sync/version.txt does not exist when sync items are present for fetchSyncInfo', (async () => { Setting.setValue('sync.wipeOutFailSafe', true); const note = { id: 1, type_: BaseModel.TYPE_NOTE, }; await BaseItem.saveSyncTime(fileApi().syncTargetId(), note, 1); await expect(fetchSyncInfo(fileApi())).rejects.toThrow('Fail-safe: '); })); it('should succeed when info.json and .sync/version.txt does not exist when sync items are not present for fetchSyncInfo', (async () => { Setting.setValue('sync.wipeOutFailSafe', true); await expect(fetchSyncInfo(fileApi())).resolves.not.toThrow(); })); it('should merge revision service settings based on timestamps', () => { const s1 = new SyncInfo(); s1.revisionServiceEnabled = false; s1.revisionServiceTtlDays = 30; const s2 = new SyncInfo(); s2.revisionServiceEnabled = true; s2.revisionServiceTtlDays = 90; s1.setKeyTimestamp('revisionServiceEnabled', 200); s1.setKeyTimestamp('revisionServiceTtlDays', 100); s2.setKeyTimestamp('revisionServiceEnabled', 100); s2.setKeyTimestamp('revisionServiceTtlDays', 200); const merged = mergeSyncInfos(s1, s2); expect(merged.revisionServiceEnabled).toBe(false); expect(merged.revisionServiceTtlDays).toBe(90); }); it('should use default revision service settings when not present in sync info', () => { const s = new SyncInfo(JSON.stringify({ version: 3 })); expect(s.revisionServiceEnabled).toBe(true); expect(s.revisionServiceTtlDays).toBe(90); }); it('should update syncInfo when revision service setting changes', async () => { const s = new SyncInfo(); s.revisionServiceTtlDays = 90; s.setKeyTimestamp('revisionServiceTtlDays', 0); saveLocalSyncInfo(s); onRevisionServiceSettingsChanged('revisionService.ttlDays', 30); const updated = localSyncInfo(); expect(updated.revisionServiceTtlDays).toBe(30); expect(updated.keyTimestamp('revisionServiceTtlDays')).toBeGreaterThan(0); }); it('should not update syncInfo when revision service setting value is unchanged', async () => { const s = new SyncInfo(); s.revisionServiceTtlDays = 90; s.setKeyTimestamp('revisionServiceTtlDays', 0); saveLocalSyncInfo(s); onRevisionServiceSettingsChanged('revisionService.ttlDays', 90); const updated = localSyncInfo(); expect(updated.keyTimestamp('revisionServiceTtlDays')).toBe(0); }); it('should ignore unrelated keys in onRevisionServiceSettingsChanged', async () => { const s = new SyncInfo(); s.revisionServiceTtlDays = 90; s.setKeyTimestamp('revisionServiceTtlDays', 0); saveLocalSyncInfo(s); onRevisionServiceSettingsChanged('sync.target', 1); const updated = localSyncInfo(); expect(updated.keyTimestamp('revisionServiceTtlDays')).toBe(0); }); });