一些检查失败了
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
632 行
24 KiB
TypeScript
632 行
24 KiB
TypeScript
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);
|
|
});
|
|
});
|