文件
mynode/packages/lib/services/noteLock/NoteLockKeyMigration.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

252 行
13 KiB
TypeScript

import Setting from '../../models/Setting';
import Note from '../../models/Note';
import uuid from '../../uuid';
import { afterAllCleanUp, db, encryptionService, setupDatabaseAndSynchronizer, switchClient } from '../../testing/test-utils';
import EncryptionService from '../e2ee/EncryptionService';
import { MasterKeyEntity } from '../e2ee/types';
import { localSyncInfo, noteLockKeyConflict } from '../synchronizer/syncInfoUtils';
import NoteLockKey, { DecryptedNoteLockKey } from './NoteLockKey';
import NoteLockNote from './NoteLockNote';
import NoteLockService from './NoteLockService';
import NoteLockSession from './NoteLockSession';
import { confirmAndSkipNoteLockKeyMigration, finishNoteLockKeyMigration, migrateLockedNotes, notMigratedMessage, startNoteLockKeyMigration } from './NoteLockKeyMigration';
import eventManager, { EventName, NoteLockSessionChangeEvent } from '../../eventManager';
import shim from '../../shim';
const localPassword = '111111';
const targetPassword = '222222';
const targetSyncMigrationId = '0123456789abcdef0123456789abcdef';
const generateKey = async (password: string): Promise<MasterKeyEntity> => ({ ...await encryptionService().generateMasterKey(password), id: uuid.create() });
const decryptKey = async (key: MasterKeyEntity, password: string): Promise<DecryptedNoteLockKey> => ({ id: key.id, plainText: await encryptionService().decryptMasterKeyContent(key, password) });
// Encrypts outside the session so the fixture can use a key the session would refuse.
const noteLockedWith = async (key: DecryptedNoteLockKey, body: string) => {
const cipherText = await encryptionService().encryptString(body, { masterKeyId: key.id, decryptedMasterKey: key.plainText, isNoteLock: true });
return Note.save({ title: body, body: cipherText, is_locked: 1 });
};
const bodyDecryptedWith = async (noteId: string, key: DecryptedNoteLockKey) => (await NoteLockNote.decryptBody(await Note.load(noteId), key)).body;
const storedBodies = async () => (await Note.all({ fields: ['id', 'body'] })).map(n => n.body).sort();
describe('NoteLockKeyMigration', () => {
let targetKey: MasterKeyEntity;
let decryptedTargetKey: DecryptedNoteLockKey;
let decryptedLocalKey: DecryptedNoteLockKey;
beforeEach(async () => {
await setupDatabaseAndSynchronizer(1);
await switchClient(1);
NoteLockService.destroyInstance();
NoteLockSession.destroyInstance();
NoteLockKey.destroyInstance();
EncryptionService.instance_ = encryptionService();
Setting.setValue('featureFlag.noteLock', true);
await NoteLockKey.instance().create(localPassword);
await NoteLockSession.instance().unlock(localPassword);
decryptedLocalKey = await NoteLockKey.instance().decrypt(localPassword);
targetKey = await generateKey(targetPassword);
decryptedTargetKey = await decryptKey(targetKey, targetPassword);
Setting.setValue('noteLock.conflictNoteLockKey', { noteLockKey: targetKey, syncMigrationId: targetSyncMigrationId });
});
afterAll(async () => {
await afterAllCleanUp();
});
it('should re-encrypt the notes locked with the local key and report the rest', async () => {
const note1 = await Note.save({ title: 'one', body: 'one', is_locked: 1 }, { useNoteLock: true });
const note2 = await Note.save({ title: 'two', body: 'two', is_locked: 1 }, { useNoteLock: true });
const staleKey = await generateKey('333333');
const staleNote = await noteLockedWith(await decryptKey(staleKey, '333333'), 'stale');
const corruptCipherText = (await Note.load(note1.id)).body;
const corruptNote = await Note.save({ title: 'corrupt', body: `${corruptCipherText.slice(0, -8)}xxxxxxxx`, is_locked: 1 });
await Note.save({ title: 'plain', body: 'plain' });
const localKeyBefore = NoteLockKey.instance().load();
const result = await migrateLockedNotes(localPassword, targetPassword);
expect(result).toEqual({ migrated: 2, skipped: 1, failed: 1, awaitingDecryption: 0 });
expect(await bodyDecryptedWith(note1.id, decryptedTargetKey)).toBe('one');
expect(await bodyDecryptedWith(note2.id, decryptedTargetKey)).toBe('two');
expect((await Note.load(staleNote.id)).body).toBe(staleNote.body);
expect((await Note.load(corruptNote.id)).body).toBe(corruptNote.body);
expect((await Note.load(note1.id)).is_locked).toBe(1);
// Nothing is adopted until the user finishes, so an interrupted migration loses no key.
expect(NoteLockKey.instance().load()).toEqual(localKeyBefore);
expect(noteLockKeyConflict()).toEqual({ noteLockKey: targetKey, syncMigrationId: targetSyncMigrationId });
});
it('should leave notes already under the target key alone on retry', async () => {
await Note.save({ title: 'one', body: 'one', is_locked: 1 }, { useNoteLock: true });
const staleKey = await generateKey('333333');
await noteLockedWith(await decryptKey(staleKey, '333333'), 'stale');
await migrateLockedNotes(localPassword, targetPassword);
const bodiesAfterFirstRun = await storedBodies();
expect(await migrateLockedNotes(localPassword, targetPassword)).toEqual({ migrated: 0, skipped: 1, failed: 0, awaitingDecryption: 0 });
expect(await storedBodies()).toEqual(bodiesAfterFirstRun);
});
test.each([
['local', 'wrong', targetPassword],
['target', localPassword, 'wrong'],
])('should not touch any note when the %s password is wrong', async (_which, local, target) => {
await Note.save({ title: 'one', body: 'one', is_locked: 1 }, { useNoteLock: true });
const bodiesBefore = await storedBodies();
await expect(migrateLockedNotes(local, target)).rejects.toThrow();
expect(await storedBodies()).toEqual(bodiesBefore);
});
it('should fail closed when note lock is disabled', async () => {
await Note.save({ title: 'one', body: 'one', is_locked: 1 }, { useNoteLock: true });
const bodiesBefore = await storedBodies();
Setting.setValue('featureFlag.noteLock', false);
await expect(migrateLockedNotes(localPassword, targetPassword)).rejects.toThrow('not enabled');
expect(await storedBodies()).toEqual(bodiesBefore);
});
it('should adopt the target key and lineage, clear the flags and lock the session on finish', async () => {
Setting.setValue('noteLock.passwordReset', true);
expect(NoteLockSession.instance().isUnlocked()).toBe(true);
const events: NoteLockSessionChangeEvent[] = [];
const listener = (event: NoteLockSessionChangeEvent) => events.push(event);
eventManager.on(EventName.NoteLockSessionChange, listener);
try {
finishNoteLockKeyMigration();
// The lock is announced right away rather than on the next session check, so the UI follows.
expect(events).toEqual([{ unlocked: false }]);
expect(NoteLockKey.instance().load()).toEqual(targetKey);
expect(localSyncInfo().syncMigrationId).toBe(targetSyncMigrationId);
expect(noteLockKeyConflict()).toBeNull();
expect(Setting.value('noteLock.passwordReset')).toBe(false);
await NoteLockSession.instance().unlock(targetPassword);
expect(NoteLockSession.instance().isUnlocked()).toBe(true);
} finally {
eventManager.off(EventName.NoteLockSessionChange, listener);
}
});
test.each([
['Yes', 0, true],
['No', 1, false],
])('should skip the migration only when Yes is chosen in the warning (answer: %s)', async (_answer, response, skipped) => {
const showMessageBox = jest.spyOn(shim, 'showMessageBox').mockResolvedValue(response);
const dispatch = jest.fn();
expect(await confirmAndSkipNoteLockKeyMigration(dispatch)).toBe(skipped);
expect(showMessageBox.mock.calls[0][0]).toContain('JEX backup');
expect(showMessageBox.mock.calls[0][1].buttons).toEqual(['Yes', 'No']);
expect(NoteLockKey.instance().load().id).toBe(skipped ? targetKey.id : decryptedLocalKey.id);
expect(dispatch).toHaveBeenCalledTimes(skipped ? 1 : 0);
showMessageBox.mockRestore();
});
it('should report a background run that starts once the passwords are accepted', async () => {
const note = await Note.save({ title: 'one', body: 'one', is_locked: 1 }, { useNoteLock: true });
const dispatch = jest.fn();
const onStarted = jest.fn();
await expect(startNoteLockKeyMigration(localPassword, 'wrong', dispatch, onStarted)).rejects.toThrow();
expect(onStarted).not.toHaveBeenCalled();
expect(dispatch).not.toHaveBeenCalled();
const corruptNote = await Note.save({ title: 'corrupt', body: `${(await Note.load(note.id)).body.slice(0, -8)}xxxxxxxx`, is_locked: 1 });
await startNoteLockKeyMigration(localPassword, targetPassword, dispatch, onStarted);
expect(onStarted).toHaveBeenCalledTimes(1);
expect(dispatch.mock.calls).toEqual([
[{ type: 'NOTE_LOCK_MIGRATION_STATUS_SET', value: { running: true, failed: 0, skipped: 0, awaitingDecryption: 0 } }],
[{ type: 'NOTE_LOCK_MIGRATION_STATUS_SET', value: { running: false, failed: 1, skipped: 0, awaitingDecryption: 0 } }],
]);
expect(noteLockKeyConflict()).not.toBeNull();
await Note.delete(corruptNote.id);
dispatch.mockClear();
await startNoteLockKeyMigration(localPassword, targetPassword, dispatch, onStarted);
expect(dispatch).toHaveBeenLastCalledWith({ type: 'NOTE_LOCK_MIGRATION_STATUS_SET', value: { running: false, failed: 0, skipped: 0, awaitingDecryption: 0 } });
expect(NoteLockKey.instance().load()).toEqual(targetKey);
expect(await bodyDecryptedWith(note.id, decryptedTargetKey)).toBe('one');
});
it('should cancel a run with a popup when the feature flag is turned off during it', async () => {
await Note.save({ title: 'one', body: 'one', is_locked: 1 }, { useNoteLock: true });
const dispatch = jest.fn();
const showErrorDialog = jest.spyOn(shim, 'showErrorDialog').mockResolvedValue();
const save = Note.save.bind(Note);
const saveSpy = jest.spyOn(Note, 'save').mockImplementation((o, options) => {
Setting.setValue('featureFlag.noteLock', false);
return save(o, options);
});
try {
await startNoteLockKeyMigration(localPassword, targetPassword, dispatch, jest.fn());
expect(dispatch).toHaveBeenLastCalledWith({ type: 'NOTE_LOCK_MIGRATION_STATUS_SET', value: null });
expect(showErrorDialog).toHaveBeenCalledWith('The note lock key migration was cancelled because the note lock feature was turned off.');
expect(noteLockKeyConflict()).not.toBeNull();
} finally {
saveSpy.mockRestore();
showErrorDialog.mockRestore();
}
});
it('should keep locked notes closed and refuse a password reset only while a run goes', async () => {
await Note.save({ title: 'one', body: 'one', is_locked: 1 }, { useNoteLock: true });
let unlockDuringRun: Promise<Error> = null;
let resetDuringRun: Promise<Error> = null;
await migrateLockedNotes(localPassword, targetPassword, () => {
expect(NoteLockSession.instance().isUnlocked()).toBe(false);
unlockDuringRun = NoteLockSession.instance().unlock(localPassword).then((): Error => null, (error: Error) => error);
resetDuringRun = NoteLockSession.instance().reset('333333').then((): Error => null, (error: Error) => error);
});
expect((await unlockDuringRun)?.message).toContain('note lock key migration is in progress');
expect((await resetDuringRun)?.message).toContain('cannot be reset while a note lock key migration is in progress');
await NoteLockSession.instance().unlock(localPassword);
expect(NoteLockSession.instance().isUnlocked()).toBe(true);
});
it('should refuse to migrate or finish without a parked key', async () => {
Setting.setValue('noteLock.conflictNoteLockKey', {});
await expect(migrateLockedNotes(localPassword, targetPassword)).rejects.toThrow('No note lock key conflict');
expect(() => finishNoteLockKeyMigration()).toThrow('No note lock key conflict');
expect(NoteLockKey.instance().load().id).toBe(decryptedLocalKey.id);
});
it('should keep the date a note was last changed by the user', async () => {
const note = await Note.save({ title: 'one', body: 'one', is_locked: 1 }, { useNoteLock: true });
await db().exec('UPDATE notes SET user_updated_time = 1000, updated_time = 1000 WHERE id = ?', [note.id]);
expect(await migrateLockedNotes(localPassword, targetPassword)).toEqual({ migrated: 1, skipped: 0, failed: 0, awaitingDecryption: 0 });
const migrated = await Note.load(note.id);
expect(migrated.user_updated_time).toBe(1000);
// Still moves, so the re-encrypted note is synced.
expect(migrated.updated_time).toBeGreaterThan(1000);
});
it('should leave a note still waiting for sync decryption for a retry and not finish the migration', async () => {
const waiting = await Note.save({ title: 'waiting', body: 'waiting', is_locked: 1 }, { useNoteLock: true });
// What sync leaves until the decryption worker has run: is_locked stays readable, the body is not there yet.
await db().exec('UPDATE notes SET encryption_applied = 1, body = \'\' WHERE id = ?', [waiting.id]);
const dispatch = jest.fn();
await startNoteLockKeyMigration(localPassword, targetPassword, dispatch, jest.fn());
const status = { running: false, failed: 0, skipped: 0, awaitingDecryption: 1 };
expect(dispatch).toHaveBeenLastCalledWith({ type: 'NOTE_LOCK_MIGRATION_STATUS_SET', value: status });
expect(noteLockKeyConflict()).not.toBeNull();
expect(notMigratedMessage(status)).toBe('1 locked note is still waiting to be decrypted and was not migrated yet.');
});
});