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

220 行
9.5 KiB
TypeScript

import Setting from '../../models/Setting';
import { afterAllCleanUp, encryptionService, fileContentEqual, setupDatabaseAndSynchronizer, supportDir, switchClient } from '../../testing/test-utils';
import EncryptionService, { EncryptionMethod } from '../e2ee/EncryptionService';
import { localSyncInfo, saveLocalSyncInfo } from '../synchronizer/syncInfoUtils';
import NoteLockKey from './NoteLockKey';
import NoteLockSession from './NoteLockSession';
import NoteLockService, { ScopedNoteLockService } from './NoteLockService';
const unlockedSession = async (password = '123456') => {
const session = NoteLockSession.instance();
await NoteLockKey.instance().create(password);
await session.unlock(password);
return session;
};
const changeSyncedKeyId = (id: string) => {
const syncInfo = localSyncInfo();
syncInfo.noteLockKey = { ...syncInfo.noteLockKey, id };
saveLocalSyncInfo(syncInfo);
};
describe('NoteLockService', () => {
beforeEach(async () => {
await setupDatabaseAndSynchronizer(1);
await switchClient(1);
NoteLockService.destroyInstance();
NoteLockSession.destroyInstance();
NoteLockKey.destroyInstance();
EncryptionService.instance_ = encryptionService();
});
afterAll(async () => {
await afterAllCleanUp();
});
it('should encrypt and decrypt strings and files without loading an E2EE master key', async () => {
const encryptionServiceInstance = EncryptionService.instance();
const noteLockKey = NoteLockKey.instance();
const session = NoteLockSession.instance();
const service = NoteLockService.instance();
await noteLockKey.create('123456');
await session.unlock('123456');
const cipherText = await service.encryptString('some secret');
// Note lock data carries its own header prefix so a JED prefix check cannot mistake it
// for sync E2EE data.
expect(cipherText.startsWith('JLD01')).toBe(true);
expect(await service.decryptString(cipherText)).toBe('some secret');
const sourcePath = `${supportDir}/photo.jpg`;
const encryptedPath = `${Setting.value('tempDir')}/note-lock-photo.crypted`;
const decryptedPath = `${Setting.value('tempDir')}/note-lock-photo.jpg`;
await service.encryptFile(sourcePath, encryptedPath);
await service.decryptFile(encryptedPath, decryptedPath);
expect(fileContentEqual(sourcePath, encryptedPath)).toBe(false);
expect(fileContentEqual(sourcePath, decryptedPath)).toBe(true);
expect(encryptionServiceInstance.loadedMasterKeysCount()).toBe(0);
});
it('should refuse to run a held operation while the session is locked', async () => {
await NoteLockKey.instance().create('123456');
await expect(NoteLockService.withDecryptedKey(async () => {})).rejects.toThrow('Note lock session is locked');
});
it('should change the note lock password without rotating the key', async () => {
const noteLockKey = NoteLockKey.instance();
const session = NoteLockSession.instance();
const service = NoteLockService.instance();
const firstKey = await noteLockKey.create('123456');
await session.unlock('123456');
const cipherText = await service.encryptString('some secret');
const changedKey = await noteLockKey.changePassword('123456', '654321');
expect(changedKey.id).toBe(firstKey.id);
expect(changedKey.content).not.toBe(firstKey.content);
// Same key id and decrypted content, so an already-unlocked session keeps working.
expect(session.isUnlocked()).toBe(true);
expect(await service.decryptString(cipherText)).toBe('some secret');
session.lock();
await expect(session.unlock('123456')).rejects.toThrow();
await session.unlock('654321');
expect(await service.decryptString(cipherText)).toBe('some secret');
});
it('should upgrade the note lock key without rotating it', async () => {
const noteLockKey = NoteLockKey.instance();
const session = NoteLockSession.instance();
const service = NoteLockService.instance();
const oldKey = noteLockKey.save(await EncryptionService.instance().generateMasterKey('123456', {
encryptionMethod: EncryptionMethod.SJCL2,
}));
await session.unlock('123456');
const cipherText = await service.encryptString('some secret');
expect(noteLockKey.needsUpgrade()).toBe(true);
const upgradedKey = await noteLockKey.upgrade('123456');
expect(upgradedKey.id).toBe(oldKey.id);
expect(upgradedKey.content).not.toBe(oldKey.content);
expect(noteLockKey.needsUpgrade()).toBe(false);
expect(await service.decryptString(cipherText)).toBe('some secret');
});
it('should keep a held operation working after the session locks, refuse the session-backed service, and revoke the scoped service afterwards', async () => {
const service = NoteLockService.instance();
const session = await unlockedSession();
const cipherText = await service.encryptString('some secret');
let escaped: ScopedNoteLockService = null;
await NoteLockService.withDecryptedKey(async scoped => {
escaped = scoped;
session.lock();
await expect(service.decryptString(cipherText)).rejects.toThrow('Note lock session is locked');
expect(await scoped.decryptString(cipherText)).toBe('some secret');
const lockedEncryptedText = await scoped.encryptString('encrypted while locked');
expect(await scoped.decryptString(lockedEncryptedText)).toBe('encrypted while locked');
});
await expect(service.decryptString(cipherText)).rejects.toThrow('Note lock session is locked');
await expect(escaped.decryptString(cipherText)).rejects.toThrow('Note lock operation key is no longer available');
});
it('should keep a held operation on its captured key when the synced key changes, and fail the session-backed service closed', async () => {
const service = NoteLockService.instance();
await unlockedSession();
const cipherText = await service.encryptString('some secret');
await NoteLockService.withDecryptedKey(async scoped => {
changeSyncedKeyId('0123456789abcdef0123456789abcdef');
expect(await scoped.decryptString(cipherText)).toBe('some secret');
await expect(service.encryptString('unrelated')).rejects.toThrow('Note lock session is locked');
await expect(scoped.encryptString('unrelated')).rejects.toThrow('Note lock key changed during operation');
});
});
it('should fail a held encrypt closed while a reset is in progress', async () => {
const session = await unlockedSession();
await NoteLockService.withDecryptedKey(async scoped => {
// Don't await yet: reset() sets the rotating flag synchronously before its first await, which is
// what the held encrypt below needs to observe.
const resetting = session.reset('654321');
await expect(scoped.encryptString('some secret')).rejects.toThrow('Note lock key changed during operation');
await resetting;
});
});
it('should fail a held encryptFile closed and remove its output if the key rotates mid-encrypt', async () => {
await unlockedSession();
const sourcePath = `${supportDir}/photo.jpg`;
const destPath = `${Setting.value('tempDir')}/note-lock-rotate-mid-encrypt.crypted`;
const fsDriver = EncryptionService.instance().fsDriver();
await NoteLockService.withDecryptedKey(async scoped => {
// Don't await yet: encryptFile() doesn't reach its own first await until after the pre-check, so
// this mutation is guaranteed to land before the post-check below runs.
const encrypting = scoped.encryptFile(sourcePath, destPath);
changeSyncedKeyId('0123456789abcdef0123456789abcdef');
await expect(encrypting).rejects.toThrow('Note lock key changed during operation');
});
expect(await fsDriver.exists(destPath)).toBe(false);
});
it('should fail the live service closed and remove its output if the key rotates mid-encrypt', async () => {
await unlockedSession();
const service = NoteLockService.instance();
const sourcePath = `${supportDir}/photo.jpg`;
const destPath = `${Setting.value('tempDir')}/note-lock-rotate-mid-encrypt-live.crypted`;
const fsDriver = EncryptionService.instance().fsDriver();
// Don't await yet: encryptFile() doesn't reach its own first await until after the pre-check, so this
// mutation is guaranteed to land before the post-check below runs.
const encrypting = service.encryptFile(sourcePath, destPath);
changeSyncedKeyId('0123456789abcdef0123456789abcdef');
await expect(encrypting).rejects.toThrow('Note lock key changed during operation');
expect(await fsDriver.exists(destPath)).toBe(false);
});
it('should propagate errors from a held operation and still revoke the scoped service', async () => {
const session = NoteLockSession.instance();
await NoteLockKey.instance().create('123456');
await session.unlock('123456');
let escaped: ScopedNoteLockService = null;
await expect(NoteLockService.withDecryptedKey(async scoped => {
escaped = scoped;
throw new Error('boom');
})).rejects.toThrow('boom');
await expect(escaped.decryptString('does not matter')).rejects.toThrow('Note lock operation key is no longer available');
});
it('should not overwrite a key that arrives through sync while create is generating', async () => {
const encryptionServiceInstance = EncryptionService.instance();
const realGenerate = encryptionServiceInstance.generateMasterKey.bind(encryptionServiceInstance);
const spy = jest.spyOn(encryptionServiceInstance, 'generateMasterKey').mockImplementation(async (password: string) => {
const generated = await realGenerate(password);
const syncInfo = localSyncInfo();
syncInfo.noteLockKey = { id: 'synced-key' };
saveLocalSyncInfo(syncInfo);
return generated;
});
try {
await expect(NoteLockKey.instance().create('123456')).rejects.toThrow('Note lock key already exists');
expect(NoteLockKey.instance().load().id).toBe('synced-key');
} finally {
spy.mockRestore();
}
});
});