import { afterAllCleanUp, setupDatabaseAndSynchronizer, logger, switchClient, encryptionService, msleep } from '../../testing/test-utils'; import MasterKey from '../../models/MasterKey'; import { checkIfCanSync, localSyncInfo, masterKeyEnabled, mergeSyncInfos, saveLocalSyncInfo, setMasterKeyEnabled, SyncInfo, syncInfoEquals } from './syncInfoUtils'; import Setting from '../../models/Setting'; import Logger from '@joplin/utils/Logger'; describe('syncInfoUtils', () => { beforeEach(async () => { await setupDatabaseAndSynchronizer(1); await switchClient(1); }); 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 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'); }); 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, }, ], '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', '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, }, ], '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...', }, }, '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], ])('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 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.0.0'); expect(result.masterKeys).toEqual([]); Logger.globalLogger.enabled = true; }); });