文件
mynode/packages/lib/services/synchronizer/Synchronizer.noteLock.test.ts
chen 301194963b
一些检查失败了
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
feat: Initial clean release of CHM
2026-11-04 11:29:39 +08:00

494 行
22 KiB
TypeScript

import Setting from '../../models/Setting';
import Note from '../../models/Note';
import { afterAllCleanUp, encryptionService, fileApi, loadEncryptionMasterKey, setupDatabaseAndSynchronizer, switchClient, synchronizer, synchronizerStart } from '../../testing/test-utils';
import EncryptionService from '../e2ee/EncryptionService';
import NoteLockKey from '../noteLock/NoteLockKey';
import NoteLockSession from '../noteLock/NoteLockSession';
import NoteLockService from '../noteLock/NoteLockService';
import { fetchSyncInfo, localSyncInfo, noteLockKeyConflict, onRevisionServiceSettingsChanged, saveLocalSyncInfo } from './syncInfoUtils';
import { ErrorCode } from '../../errors';
import NoteLockNote from '../noteLock/NoteLockNote';
import { finishNoteLockKeyMigration, migrateLockedNotes } from '../noteLock/NoteLockKeyMigration';
import { MasterKeyEntity } from '../e2ee/types';
import { setupAndEnableEncryption } from '../e2ee/utils';
// Duplicates the singleton reset from NoteLockSession.test.ts: the note lock singletons cache the
// encryption service, so each client switch has to rebuild them.
const switchToClient = async (id: number) => {
await switchClient(id);
NoteLockService.destroyInstance();
NoteLockSession.destroyInstance();
NoteLockKey.destroyInstance();
EncryptionService.instance_ = encryptionService();
};
const remoteNoteLockKeyId = async () => (await fetchSyncInfo(fileApi())).noteLockKey?.id;
const decryptedRemoteKey = async (key: MasterKeyEntity) => ({ id: key.id, plainText: await encryptionService().decryptMasterKeyContent(key, '111111') });
// Lands the action inside the running sync, after it took its local snapshot and before the conflict check.
const duringTheLockedNotesCheck = (action: ()=> Promise<unknown>) => {
const realHasLockedNotes = Note.hasLockedNotes.bind(Note);
const spy = jest.spyOn(Note, 'hasLockedNotes').mockImplementation(async () => {
spy.mockRestore();
await action();
return realHasLockedNotes();
});
};
describe('Synchronizer.noteLock', () => {
beforeEach(async () => {
await setupDatabaseAndSynchronizer(1);
await setupDatabaseAndSynchronizer(2);
await switchToClient(1);
});
afterAll(async () => {
await afterAllCleanUp();
});
it('should propagate a password reset to the sync target and then to other devices', async () => {
const firstKey = await NoteLockKey.instance().create('111111');
const syncMigrationId = localSyncInfo().syncMigrationId;
await Note.save({ title: 'locked', is_locked: 1 });
await synchronizerStart();
await switchToClient(2);
await synchronizerStart();
expect(NoteLockKey.instance().load().id).toBe(firstKey.id);
const resetKey = await NoteLockSession.instance().reset('222222');
await synchronizerStart();
expect(await remoteNoteLockKeyId()).toBe(resetKey.id);
expect(Setting.value('noteLock.passwordReset')).toBe(false);
// Locked notes do not stop a same-lineage adoption: a reset is meant to reach every device.
await switchToClient(1);
await synchronizerStart();
expect(NoteLockKey.instance().load()).toEqual(resetKey);
expect(localSyncInfo().syncMigrationId).toBe(syncMigrationId);
});
it('should propagate the newest of repeated offline resets to the key they replaced', async () => {
const firstKey = await NoteLockKey.instance().create('111111');
await Note.save({ title: 'locked', is_locked: 1 });
await synchronizerStart();
await switchToClient(2);
await synchronizerStart();
await NoteLockSession.instance().reset('222222');
const lastResetKey = await NoteLockSession.instance().reset('333333');
expect(Setting.value('noteLock.keyIdToReset')).toBe(firstKey.id);
await synchronizerStart();
expect(await remoteNoteLockKeyId()).toBe(lastResetKey.id);
expect(Setting.value('noteLock.passwordReset')).toBe(false);
expect(Setting.value('noteLock.keyIdToReset')).toBe('');
await switchToClient(1);
await synchronizerStart();
expect(NoteLockKey.instance().load()).toEqual(lastResetKey);
});
it('should park the sync target key instead of propagating a reset when another device reset first', async () => {
Setting.setValue('featureFlag.noteLock', true);
await NoteLockKey.instance().create('111111');
await synchronizerStart();
await switchToClient(2);
Setting.setValue('featureFlag.noteLock', true);
await synchronizerStart();
await NoteLockSession.instance().reset('222222');
await NoteLockSession.instance().unlock('222222');
const note = await Note.save({ title: 'secret', body: 'secret body', is_locked: 1 }, { useNoteLock: true });
// The other device resets and syncs first, so the key this reset replaced is gone from the target.
await switchToClient(1);
const winningKey = await NoteLockSession.instance().reset('333333');
await synchronizerStart();
expect(await remoteNoteLockKeyId()).toBe(winningKey.id);
await switchToClient(2);
for (let i = 0; i < 2; i++) {
await expect(synchronizerStart(null, { throwOnError: true })).rejects.toMatchObject({ code: ErrorCode.NoteLockKeyConflict });
expect(await remoteNoteLockKeyId()).toBe(winningKey.id);
expect(Setting.value('noteLock.conflictNoteLockKey').noteLockKey).toEqual(winningKey);
}
expect(await migrateLockedNotes('222222', '333333')).toEqual({ migrated: 1, skipped: 0, failed: 0, awaitingDecryption: 0 });
finishNoteLockKeyMigration();
expect(Setting.value('noteLock.passwordReset')).toBe(false);
expect(Setting.value('noteLock.keyIdToReset')).toBe('');
await synchronizerStart(null, { throwOnError: true });
expect(NoteLockKey.instance().load()).toEqual(winningKey);
expect(await remoteNoteLockKeyId()).toBe(winningKey.id);
const key = await NoteLockKey.instance().decrypt('333333');
expect((await NoteLockNote.decryptBody(await Note.load(note.id), key)).body).toBe('secret body');
});
it('should drop a reset that another device overtook when no note depends on it', async () => {
await NoteLockKey.instance().create('111111');
await synchronizerStart();
await switchToClient(2);
await synchronizerStart();
await NoteLockSession.instance().reset('222222');
await switchToClient(1);
const winningKey = await NoteLockSession.instance().reset('333333');
await synchronizerStart();
await switchToClient(2);
await synchronizerStart();
expect(NoteLockKey.instance().load()).toEqual(winningKey);
expect(await remoteNoteLockKeyId()).toBe(winningKey.id);
expect(Setting.value('noteLock.passwordReset')).toBe(false);
expect(Setting.value('noteLock.keyIdToReset')).toBe('');
expect(Setting.value('noteLock.conflictNoteLockKey')).toEqual({});
});
it('should stop the sync and park the remote key when a different lineage meets local locked notes', async () => {
const remoteKey = await NoteLockKey.instance().create('111111');
const remoteSyncMigrationId = localSyncInfo().syncMigrationId;
await synchronizerStart();
await switchToClient(2);
const localKey = await NoteLockKey.instance().create('222222');
const localSyncMigrationId = localSyncInfo().syncMigrationId;
await Note.save({ title: 'locked', is_locked: 1 });
for (let i = 0; i < 2; i++) {
await expect(synchronizerStart(null, { throwOnError: true })).rejects.toMatchObject({ code: ErrorCode.NoteLockKeyConflict });
expect(NoteLockKey.instance().load()).toEqual(localKey);
expect(localSyncInfo().syncMigrationId).toBe(localSyncMigrationId);
expect(await remoteNoteLockKeyId()).toBe(remoteKey.id);
expect(Setting.value('noteLock.conflictNoteLockKey')).toEqual({ noteLockKey: remoteKey, syncMigrationId: remoteSyncMigrationId });
}
});
it('should drop a local key that no note depends on and adopt the sync target key', async () => {
const remoteKey = await NoteLockKey.instance().create('111111');
const remoteSyncMigrationId = localSyncInfo().syncMigrationId;
await synchronizerStart();
await switchToClient(2);
await NoteLockKey.instance().create('222222');
await synchronizerStart();
expect(NoteLockKey.instance().load()).toEqual(remoteKey);
expect(localSyncInfo().syncMigrationId).toBe(remoteSyncMigrationId);
expect(await remoteNoteLockKeyId()).toBe(remoteKey.id);
expect(Setting.value('noteLock.conflictNoteLockKey')).toEqual({});
});
it('should keep the sync stopped and say how to migrate when note lock is off', async () => {
Setting.setValue('featureFlag.noteLock', true);
await NoteLockKey.instance().create('111111');
await synchronizerStart();
await switchToClient(2);
Setting.setValue('featureFlag.noteLock', true);
const localKey = await NoteLockKey.instance().create('222222');
await NoteLockSession.instance().unlock('222222');
await Note.save({ title: 'secret', body: 'secret body', is_locked: 1 }, { useNoteLock: true });
Setting.setValue('featureFlag.noteLock', false);
for (let i = 0; i < 2; i++) {
await expect(synchronizerStart(null, { throwOnError: true })).rejects.toThrow('Enable the note lock feature');
}
expect(NoteLockKey.instance().load()).toEqual(localKey);
});
it('should sync again after migrating local locked notes to the sync target key', async () => {
Setting.setValue('featureFlag.noteLock', true);
const remoteKey = await NoteLockKey.instance().create('111111');
const remoteSyncMigrationId = localSyncInfo().syncMigrationId;
await synchronizerStart();
await switchToClient(2);
Setting.setValue('featureFlag.noteLock', true);
await NoteLockKey.instance().create('222222');
await NoteLockSession.instance().unlock('222222');
const note = await Note.save({ title: 'secret', body: 'secret body', is_locked: 1 }, { useNoteLock: true });
await expect(synchronizerStart(null, { throwOnError: true })).rejects.toMatchObject({ code: ErrorCode.NoteLockKeyConflict });
expect(await migrateLockedNotes('222222', '111111')).toEqual({ migrated: 1, skipped: 0, failed: 0, awaitingDecryption: 0 });
finishNoteLockKeyMigration();
await synchronizerStart(null, { throwOnError: true });
expect(NoteLockKey.instance().load()).toEqual(remoteKey);
expect(localSyncInfo().syncMigrationId).toBe(remoteSyncMigrationId);
expect(await remoteNoteLockKeyId()).toBe(remoteKey.id);
await switchToClient(1);
await synchronizerStart();
const key = await NoteLockKey.instance().decrypt('111111');
expect((await NoteLockNote.decryptBody(await Note.load(note.id), key)).body).toBe('secret body');
});
it('should adopt the sync target key before the upload, so a note cannot be locked with the dropped key mid-sync', async () => {
Setting.setValue('featureFlag.noteLock', true);
const remoteKey = await NoteLockKey.instance().create('111111');
await synchronizerStart();
await switchToClient(2);
Setting.setValue('featureFlag.noteLock', true);
await NoteLockKey.instance().create('222222');
await NoteLockSession.instance().unlock('222222');
// Tries to lock a note with the local key while the info.json upload is in flight.
const api = synchronizer().api();
const realPut = api.put.bind(api);
let lateLockError: Error = null;
const putSpy = jest.spyOn(api, 'put').mockImplementation(async (path, content, options) => {
if (path === 'info.json' && !lateLockError) {
try {
await Note.save({ title: 'late', body: 'late body', is_locked: 1 }, { useNoteLock: true });
} catch (error) {
lateLockError = error;
}
}
return realPut(path, content, options);
});
try {
await synchronizerStart(null, { throwOnError: true });
} finally {
putSpy.mockRestore();
}
expect(lateLockError?.message).toBe('Note lock session is locked');
expect(NoteLockKey.instance().load()).toEqual(remoteKey);
expect(noteLockKeyConflict()).toBeNull();
});
it('should keep a reset that lands mid-sync for the next sync instead of clearing its flag', async () => {
await NoteLockKey.instance().create('111111');
await synchronizerStart();
await switchToClient(2);
await synchronizerStart();
let resetKey: MasterKeyEntity = null;
duringTheLockedNotesCheck(async () => { resetKey = await NoteLockSession.instance().reset('222222'); });
await synchronizerStart(null, { throwOnError: true });
expect(NoteLockKey.instance().load()).toEqual(resetKey);
expect(Setting.value('noteLock.passwordReset')).toBe(true);
await synchronizerStart(null, { throwOnError: true });
expect(await remoteNoteLockKeyId()).toBe(resetKey.id);
expect(Setting.value('noteLock.passwordReset')).toBe(false);
});
it('should not spend a leftover reset flag on a key that moved mid-sync', async () => {
await NoteLockKey.instance().create('111111');
await synchronizerStart();
await switchToClient(2);
await synchronizerStart();
// A flag left behind by an interrupted earlier sync.
Setting.setValue('noteLock.passwordReset', true);
let resetKey: MasterKeyEntity = null;
duringTheLockedNotesCheck(async () => { resetKey = await NoteLockSession.instance().reset('222222'); });
await expect(synchronizerStart(null, { throwOnError: true })).rejects.toThrow('changed on this device');
expect(Setting.value('noteLock.passwordReset')).toBe(true);
await synchronizerStart(null, { throwOnError: true });
expect(await remoteNoteLockKeyId()).toBe(resetKey.id);
expect(Setting.value('noteLock.passwordReset')).toBe(false);
});
it('should hand a reset that lands after the local save the key the sync target holds', async () => {
await NoteLockKey.instance().create('111111');
await synchronizerStart();
await switchToClient(2);
await synchronizerStart();
const firstResetKey = await NoteLockSession.instance().reset('222222');
// Resets again while the sync is finishing up, after the merged info was saved locally.
const handler = synchronizer().lockHandler();
const realReleaseLock = handler.releaseLock.bind(handler);
let secondResetKey: MasterKeyEntity = null;
const spy = jest.spyOn(handler, 'releaseLock').mockImplementation(async (...args) => {
spy.mockRestore();
secondResetKey = await NoteLockSession.instance().reset('333333');
return realReleaseLock(...args);
});
await synchronizerStart(null, { throwOnError: true });
expect(await remoteNoteLockKeyId()).toBe(firstResetKey.id);
expect(Setting.value('noteLock.passwordReset')).toBe(true);
expect(Setting.value('noteLock.keyIdToReset')).toBe(firstResetKey.id);
await synchronizerStart(null, { throwOnError: true });
expect(await remoteNoteLockKeyId()).toBe(secondResetKey.id);
expect(Setting.value('noteLock.passwordReset')).toBe(false);
});
it('should back up the key the target holds when a sync finishes while a second reset is generating its key', async () => {
await NoteLockKey.instance().create('111111');
await synchronizerStart();
await switchToClient(2);
await synchronizerStart();
const firstResetKey = await NoteLockSession.instance().reset('222222');
const service = encryptionService();
const realGenerate = service.generateMasterKey.bind(service);
const spy = jest.spyOn(service, 'generateMasterKey').mockImplementation(async (...args) => {
spy.mockRestore();
await synchronizerStart(null, { throwOnError: true });
return realGenerate(...args);
});
const secondResetKey = await NoteLockSession.instance().reset('333333');
expect(Setting.value('noteLock.keyIdToReset')).toBe(firstResetKey.id);
await synchronizerStart(null, { throwOnError: true });
expect(await remoteNoteLockKeyId()).toBe(secondResetKey.id);
});
it('should adopt the sync target key in the sync that first uses a local master key', async () => {
const remoteKey = await NoteLockKey.instance().create('111111');
await synchronizerStart();
await switchToClient(2);
await setupAndEnableEncryption(encryptionService(), await loadEncryptionMasterKey());
await synchronizerStart(null, { throwOnError: true });
expect(NoteLockKey.instance().load()).toEqual(remoteKey);
});
it('should keep a password change that lands during the info upload', async () => {
await NoteLockKey.instance().create('111111');
await synchronizerStart();
await switchToClient(2);
await synchronizerStart();
await switchToClient(1);
await NoteLockKey.instance().changePassword('111111', '222222');
await synchronizerStart();
await switchToClient(2);
const api = synchronizer().api();
const realPut = api.put.bind(api);
const putSpy = jest.spyOn(api, 'put').mockImplementation(async (path, content, options) => {
if (path === 'info.json') {
putSpy.mockRestore();
await NoteLockKey.instance().changePassword('111111', '333333');
}
return realPut(path, content, options);
});
try {
await expect(synchronizerStart(null, { throwOnError: true })).rejects.toThrow('changed on this device');
} finally {
putSpy.mockRestore();
}
await expect(NoteLockKey.instance().decrypt('333333')).resolves.toBeTruthy();
});
it('should not park the key again when the migration finishes while a sync is in flight', async () => {
Setting.setValue('featureFlag.noteLock', true);
const remoteKey = await NoteLockKey.instance().create('111111');
await synchronizerStart();
await switchToClient(2);
Setting.setValue('featureFlag.noteLock', true);
await NoteLockKey.instance().create('222222');
await NoteLockSession.instance().unlock('222222');
await Note.save({ title: 'secret', body: 'secret body', is_locked: 1 }, { useNoteLock: true });
await expect(synchronizerStart(null, { throwOnError: true })).rejects.toMatchObject({ code: ErrorCode.NoteLockKeyConflict });
await migrateLockedNotes('222222', '111111');
duringTheLockedNotesCheck(async () => finishNoteLockKeyMigration());
await expect(synchronizerStart(null, { throwOnError: true })).rejects.toThrow('changed on this device');
expect(noteLockKeyConflict()).toBeNull();
expect(NoteLockKey.instance().load()).toEqual(remoteKey);
await synchronizerStart(null, { throwOnError: true });
expect(await remoteNoteLockKeyId()).toBe(remoteKey.id);
});
it('should not write back a key that another device replaced after this sync fetched the target info', async () => {
await NoteLockKey.instance().create('111111');
await synchronizerStart();
const infoBeforeReset = await fileApi().get('info.json');
await switchToClient(2);
await synchronizerStart();
const resetKey = await NoteLockSession.instance().reset('222222');
await synchronizerStart();
const infoAfterReset = await fileApi().get('info.json');
// Client 1 reads the target before the reset lands, and has its own change to upload.
await switchToClient(1);
onRevisionServiceSettingsChanged('revisionService.ttlDays', 45);
await fileApi().put('info.json', infoBeforeReset);
duringTheLockedNotesCheck(() => fileApi().put('info.json', infoAfterReset));
await expect(synchronizerStart(null, { throwOnError: true })).rejects.toThrow('changed during the sync');
expect(await remoteNoteLockKeyId()).toBe(resetKey.id);
await synchronizerStart(null, { throwOnError: true });
expect(await remoteNoteLockKeyId()).toBe(resetKey.id);
expect(NoteLockKey.instance().load().id).toBe(resetKey.id);
});
it('should stop the sync when a note lock key has no sync migration id', async () => {
await NoteLockKey.instance().create('111111');
await synchronizerStart();
const remoteInfo = await fetchSyncInfo(fileApi());
remoteInfo.syncMigrationId = '';
await fileApi().put('info.json', remoteInfo.serialize());
await expect(synchronizerStart(null, { throwOnError: true })).rejects.toThrow('migration ID');
const localInfo = localSyncInfo();
localInfo.syncMigrationId = '';
saveLocalSyncInfo(localInfo);
await expect(synchronizerStart(null, { throwOnError: true })).rejects.toThrow('migration ID');
});
// Sets up client 2 with a locked note and a conflict parked against client 1's key on the sync target.
const parkConflictOnClient2 = async () => {
Setting.setValue('featureFlag.noteLock', true);
const remoteKey = await NoteLockKey.instance().create('111111');
await synchronizerStart();
await switchToClient(2);
Setting.setValue('featureFlag.noteLock', true);
const localKey = await NoteLockKey.instance().create('222222');
await NoteLockSession.instance().unlock('222222');
const note = await Note.save({ title: 'secret', body: 'secret body', is_locked: 1 }, { useNoteLock: true });
await expect(synchronizerStart(null, { throwOnError: true })).rejects.toMatchObject({ code: ErrorCode.NoteLockKeyConflict });
expect(noteLockKeyConflict()?.noteLockKey.id).toBe(remoteKey.id);
return { remoteKey, localKey, note };
};
it('should sync again when the sync target no longer conflicts, for example after switching to an empty one', async () => {
const { localKey } = await parkConflictOnClient2();
await fileApi().clearRoot();
await synchronizerStart(null, { throwOnError: true });
expect(noteLockKeyConflict()).toBeNull();
expect(NoteLockKey.instance().load()).toEqual(localKey);
expect(await remoteNoteLockKeyId()).toBe(localKey.id);
});
it('should keep the parked key once a migration has moved notes to it, even if the sync target changes', async () => {
const { remoteKey, note } = await parkConflictOnClient2();
const partner = await Note.save({ title: 'partner', body: 'partner body', is_locked: 1 }, { useNoteLock: true });
// The first note moves, the second fails, so the run stops halfway with a note already under the parked key.
const save = Note.save.bind(Note);
const spy = jest.spyOn(Note, 'save').mockImplementation(async (n, options) => {
if (n.id === partner.id) throw new Error('simulated failure');
return save(n, options);
});
try {
expect(await migrateLockedNotes('222222', '111111')).toMatchObject({ migrated: 1, failed: 1 });
} finally {
spy.mockRestore();
}
await fileApi().clearRoot();
await expect(synchronizerStart(null, { throwOnError: true })).rejects.toMatchObject({ code: ErrorCode.NoteLockKeyConflict });
expect(noteLockKeyConflict()?.noteLockKey.id).toBe(remoteKey.id);
const key = await decryptedRemoteKey(remoteKey);
expect((await NoteLockNote.decryptBody(await Note.load(note.id), key)).body).toBe('secret body');
});
});