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

398 行
18 KiB
TypeScript

import InteropService from './InteropService';
import { ExportModuleOutputFormat } from './types';
import Folder from '../../models/Folder';
import Note from '../../models/Note';
import Setting from '../../models/Setting';
import EncryptionService from '../e2ee/EncryptionService';
import NoteLockKey, { noteLockKeyFileName } from '../noteLock/NoteLockKey';
import NoteLockService from '../noteLock/NoteLockService';
import NoteLockSession from '../noteLock/NoteLockSession';
import Resource from '../../models/Resource';
import { createNoteAndResource, encryptionService, exportDir, setupDatabaseAndSynchronizer, switchClient } from '../../testing/test-utils';
import shim from '../../shim';
import * as fs from 'fs-extra';
const setUpUnlockedSession = async (password = '123456') => {
await NoteLockKey.instance().create(password);
await NoteLockSession.instance().unlock(password);
};
const lockNote = async (id: string) => {
const lockedNote = { ...(await Note.load(id)), is_locked: 1, isDecrypted: true };
await Note.save(lockedNote, { useNoteLock: true });
return Note.load(id);
};
const rotateProfileKey = async (newPassword: string) => {
NoteLockSession.instance().lock();
await NoteLockKey.instance().reset(newPassword);
await NoteLockSession.instance().unlock(newPassword);
};
describe('InteropService.noteLock', () => {
beforeEach(async () => {
await setupDatabaseAndSynchronizer(1);
await switchClient(1);
NoteLockService.destroyInstance();
NoteLockSession.destroyInstance();
NoteLockKey.destroyInstance();
EncryptionService.instance_ = encryptionService();
Setting.setValue('featureFlag.noteLock', true);
await fs.remove(exportDir());
await fs.mkdirp(exportDir());
});
it('should raw export a locked note with its ciphertext, resource and the key file', async () => {
await setUpUnlockedSession();
const folder = await Folder.save({ title: 'folder' });
const { note, resource } = await createNoteAndResource({ parentId: folder.id });
await Note.save({ id: note.id, title: 'note', body: `secret text ${note.body}` });
const locked = await lockNote(note.id);
expect(locked.body).not.toContain('secret');
await InteropService.instance().export({ path: exportDir(), format: ExportModuleOutputFormat.Raw });
const serialized = await fs.readFile(`${exportDir()}/${note.id}.md`, 'utf-8');
expect(serialized).not.toContain('secret');
expect(serialized).toContain('is_locked: 1');
expect(serialized).toContain(resource.id);
const exportedResources = await fs.readdir(`${exportDir()}/resources`);
expect(exportedResources.some(f => f.startsWith(resource.id))).toBe(true);
const keyFile = JSON.parse(await fs.readFile(`${exportDir()}/${noteLockKeyFileName}`, 'utf-8'));
expect(keyFile.id).toBe(NoteLockKey.instance().load().id);
});
it('should include the key file inside a jex archive', async () => {
await setUpUnlockedSession();
const folder = await Folder.save({ title: 'folder' });
const note = await Note.save({ title: 'note', body: 'secret', parent_id: folder.id });
await lockNote(note.id);
const jexPath = `${exportDir()}/test.jex`;
await InteropService.instance().export({ path: jexPath, format: ExportModuleOutputFormat.Jex });
const extractDir = `${exportDir()}/extracted`;
await fs.mkdirp(extractDir);
await shim.fsDriver().tarExtract({ strict: true, portable: true, file: jexPath, cwd: extractDir });
expect(await fs.pathExists(`${extractDir}/${noteLockKeyFileName}`)).toBe(true);
});
it('should md export a locked note decrypted once the session is unlocked', async () => {
await setUpUnlockedSession();
const folder = await Folder.save({ title: 'folder' });
const note = await Note.save({ title: 'locked note', body: 'secret text', parent_id: folder.id });
await lockNote(note.id);
const result = await InteropService.instance().export({ path: exportDir(), format: ExportModuleOutputFormat.Markdown, decryptLockedNotes: true });
const files = await fs.readdir(`${exportDir()}/folder`);
expect(files.length).toBe(1);
const exported = await fs.readFile(`${exportDir()}/folder/${files[0]}`, 'utf-8');
expect(exported).toContain('secret text');
expect(result.warnings).toEqual([]);
// The database row stays encrypted, only the exported copy is decrypted.
expect((await Note.load(note.id)).body).not.toContain('secret');
});
const saveLockedNotes = async (folderId: string, titles: string[]) => {
for (const title of titles) {
const note = await Note.save({ title, body: `secret ${title}`, parent_id: folderId });
await lockNote(note.id);
}
};
// Runs the action once, the first time a gated load or save starts.
const onFirstGatedCall = (method: 'load'|'save', action: ()=> void) => {
const original = (Note[method] as (...args: unknown[])=> Promise<unknown>).bind(Note);
let done = false;
return jest.spyOn(Note, method).mockImplementation(((...args: unknown[]) => {
if (!done && (args[1] as { useNoteLock?: boolean })?.useNoteLock) {
done = true;
action();
}
return original(...args);
}) as never);
};
it('should md export every locked note when the session is locked partway through', async () => {
await setUpUnlockedSession();
const folder = await Folder.save({ title: 'folder' });
await saveLockedNotes(folder.id, ['one', 'two']);
// Locks the session once the first note is done, as the second note starts loading.
const load = Note.load.bind(Note);
let plainLoads = 0;
const spy = jest.spyOn(Note, 'load').mockImplementation((id, options) => {
if (!options?.useNoteLock && ++plainLoads === 2) NoteLockSession.instance().lock();
return load(id, options);
});
try {
await InteropService.instance().export({ path: exportDir(), format: ExportModuleOutputFormat.Markdown, decryptLockedNotes: true });
} finally {
spy.mockRestore();
}
const files = await fs.readdir(`${exportDir()}/folder`);
expect(files.length).toBe(2);
for (const file of files) {
expect(await fs.readFile(`${exportDir()}/folder/${file}`, 'utf-8')).toContain('secret');
}
});
it('should skip locked notes rather than write their ciphertext when the feature flag is turned off partway through', async () => {
await setUpUnlockedSession();
const folder = await Folder.save({ title: 'folder' });
await saveLockedNotes(folder.id, ['one', 'two']);
const spy = onFirstGatedCall('load', () => Setting.setValue('featureFlag.noteLock', false));
try {
await expect(InteropService.instance().export({ path: exportDir(), format: ExportModuleOutputFormat.Markdown, decryptLockedNotes: true })).rejects.toThrow('2 locked notes could not be unlocked and were not exported.');
} finally {
spy.mockRestore();
}
expect(await fs.pathExists(`${exportDir()}/folder`) ? await fs.readdir(`${exportDir()}/folder`) : []).toEqual([]);
});
it('should skip and count locked notes when the session is locked', async () => {
await setUpUnlockedSession();
const folder = await Folder.save({ title: 'folder' });
await Note.save({ title: 'plain', body: 'plain text', parent_id: folder.id });
const locked = await Note.save({ title: 'locked', body: 'secret', parent_id: folder.id });
await lockNote(locked.id);
NoteLockSession.instance().lock();
const result = await InteropService.instance().export({ path: exportDir(), format: ExportModuleOutputFormat.Markdown, decryptLockedNotes: true });
expect(result.warnings).toEqual(['1 locked note(s) could not be unlocked and were not exported']);
expect(await fs.readdir(`${exportDir()}/folder`)).toEqual(['plain.md']);
});
it('should leave locked notes out without a warning when asked to, even with the session unlocked', async () => {
await setUpUnlockedSession();
const folder = await Folder.save({ title: 'folder' });
await Note.save({ title: 'plain', body: 'plain text', parent_id: folder.id });
const locked = await Note.save({ title: 'locked', body: 'secret', parent_id: folder.id });
await lockNote(locked.id);
const result = await InteropService.instance().export({ path: exportDir(), format: ExportModuleOutputFormat.Markdown, skipLockedNotes: true });
expect(result.warnings).toEqual([]);
expect(await fs.readdir(`${exportDir()}/folder`)).toEqual(['plain.md']);
});
it('should not decrypt locked notes for a caller that did not ask, even with the session unlocked', async () => {
await setUpUnlockedSession();
const folder = await Folder.save({ title: 'folder' });
await Note.save({ title: 'plain', body: 'plain text', parent_id: folder.id });
const locked = await Note.save({ title: 'locked', body: 'secret', parent_id: folder.id });
await lockNote(locked.id);
// As the exportFolders command does when a plugin runs it.
const result = await InteropService.instance().export({ path: exportDir(), format: ExportModuleOutputFormat.Markdown });
expect(result.warnings).toEqual(['1 locked note(s) could not be unlocked and were not exported']);
expect(await fs.readdir(`${exportDir()}/folder`)).toEqual(['plain.md']);
});
it('should skip a locked note whose content cannot be decrypted', async () => {
await setUpUnlockedSession();
const folder = await Folder.save({ title: 'folder' });
await Note.save({ title: 'broken', body: 'not ciphertext', parent_id: folder.id, is_locked: 1 });
await expect(InteropService.instance().export({ path: exportDir(), format: ExportModuleOutputFormat.Markdown, decryptLockedNotes: true })).rejects.toThrow('1 locked note could not be unlocked and was not exported.');
});
it('should not write the key file when no note is locked', async () => {
await setUpUnlockedSession();
const folder = await Folder.save({ title: 'folder' });
await Note.save({ title: 'note', body: 'plain body', parent_id: folder.id });
await InteropService.instance().export({ path: exportDir(), format: ExportModuleOutputFormat.Raw });
expect(await fs.pathExists(`${exportDir()}/${noteLockKeyFileName}`)).toBe(false);
});
it('should write the key file for a note locked with the profile key even with note lock disabled', async () => {
await setUpUnlockedSession();
const folder = await Folder.save({ title: 'folder' });
const note = await Note.save({ title: 'note', body: 'secret', parent_id: folder.id });
await lockNote(note.id);
Setting.setValue('featureFlag.noteLock', false);
await InteropService.instance().export({ path: exportDir(), format: ExportModuleOutputFormat.Raw });
expect(JSON.parse(await fs.readFile(`${exportDir()}/${noteLockKeyFileName}`, 'utf-8')).id).toBe(NoteLockKey.instance().load().id);
});
it('should not write the key file when the locked notes use another key', async () => {
await setUpUnlockedSession();
const otherKey = await encryptionService().generateMasterKey('other');
const body = await encryptionService().encryptString('secret', {
masterKeyId: '0123456789abcdef0123456789abcdef',
decryptedMasterKey: await encryptionService().decryptMasterKeyContent(otherKey, 'other'),
isNoteLock: true,
});
const folder = await Folder.save({ title: 'folder' });
await Note.save({ title: 'note', body, parent_id: folder.id, is_locked: 1 });
await InteropService.instance().export({ path: exportDir(), format: ExportModuleOutputFormat.Raw });
expect(await fs.pathExists(`${exportDir()}/${noteLockKeyFileName}`)).toBe(false);
});
it('should import a backup from the same profile with locked notes unchanged', async () => {
await setUpUnlockedSession();
const folder = await Folder.save({ title: 'folder' });
const { note, resource } = await createNoteAndResource({ parentId: folder.id });
await Note.save({ id: note.id, title: 'note', body: `secret ${note.body}` });
await lockNote(note.id);
await InteropService.instance().export({ path: exportDir(), format: ExportModuleOutputFormat.Raw });
const result = await InteropService.instance().import({ path: exportDir(), format: 'raw' });
const imported = (await Note.all()).find(n => n.id !== note.id && !!n.is_locked);
expect(imported.body).not.toContain('secret');
expect((await Note.load(imported.id, { useNoteLock: true })).body).toContain('secret');
expect(result.warnings).toEqual([]);
// The extracted list follows the remapped resource id, so the resource stays associated.
const importedResourceIds = Note.unserializeExtractedResourceIds(imported.extracted_resource_ids);
expect(importedResourceIds.length).toBe(1);
expect(importedResourceIds[0]).not.toBe(resource.id);
expect(!!(await Resource.load(importedResourceIds[0]))).toBe(true);
});
it('should re-encrypt imported locked notes for this profile through the key handler', async () => {
await setUpUnlockedSession('old password');
const folder = await Folder.save({ title: 'folder' });
const note = await Note.save({ title: 'note', body: 'secret old', parent_id: folder.id });
await lockNote(note.id);
await InteropService.instance().export({ path: exportDir(), format: ExportModuleOutputFormat.Raw });
await rotateProfileKey('new password');
const result = await InteropService.instance().import({
path: exportDir(),
format: 'raw',
onNoteLockKey: keyFile => NoteLockKey.instance().decrypt('old password', keyFile),
});
const imported = (await Note.all()).find(n => n.id !== note.id && !!n.is_locked);
expect(imported.body).not.toContain('secret');
expect((await Note.load(imported.id, { useNoteLock: true })).body).toBe('secret old');
expect(result.warnings.length).toBe(0);
});
it('should re-encrypt every imported locked note when the session is locked partway through', async () => {
await setUpUnlockedSession('old password');
const folder = await Folder.save({ title: 'folder' });
await saveLockedNotes(folder.id, ['one', 'two']);
await InteropService.instance().export({ path: exportDir(), format: ExportModuleOutputFormat.Raw });
const originalIds = (await Note.all()).map(n => n.id);
await rotateProfileKey('new password');
const spy = onFirstGatedCall('save', () => NoteLockSession.instance().lock());
try {
await InteropService.instance().import({
path: exportDir(),
format: 'raw',
onNoteLockKey: keyFile => NoteLockKey.instance().decrypt('old password', keyFile),
});
} finally {
spy.mockRestore();
}
await NoteLockSession.instance().unlock('new password');
const imported = (await Note.all()).filter(n => !originalIds.includes(n.id));
expect(imported.length).toBe(2);
for (const n of imported) expect((await Note.load(n.id, { useNoteLock: true })).body).toMatch(/^secret /);
});
it('should stop the import without writing plain text when the feature flag is turned off while a locked note is re-encrypted', async () => {
await setUpUnlockedSession('old password');
const folder = await Folder.save({ title: 'folder' });
const note = await Note.save({ title: 'note', body: 'secret old', parent_id: folder.id });
await lockNote(note.id);
await InteropService.instance().export({ path: exportDir(), format: ExportModuleOutputFormat.Raw });
await rotateProfileKey('new password');
const save = Note.save.bind(Note);
const spy = jest.spyOn(Note, 'save').mockImplementation((o, options) => {
Setting.setValue('featureFlag.noteLock', false);
return save(o, options);
});
try {
await expect(InteropService.instance().import({
path: exportDir(),
format: 'raw',
onNoteLockKey: keyFile => NoteLockKey.instance().decrypt('old password', keyFile),
})).rejects.toThrow('Locked notes cannot be saved while the note lock feature is turned off');
} finally {
spy.mockRestore();
}
expect((await Note.all()).filter(n => n.body.includes('secret'))).toEqual([]);
});
it('should import foreign locked notes unchanged when no key handler is provided', async () => {
await setUpUnlockedSession('old password');
const folder = await Folder.save({ title: 'folder' });
const note = await Note.save({ title: 'note', body: 'secret old', parent_id: folder.id });
await lockNote(note.id);
await InteropService.instance().export({ path: exportDir(), format: ExportModuleOutputFormat.Raw });
await rotateProfileKey('new password');
const result = await InteropService.instance().import({ path: exportDir(), format: 'raw' });
const imported = (await Note.all()).find(n => n.id !== note.id && !!n.is_locked);
expect(imported.body).not.toContain('secret');
await expect(Note.load(imported.id, { useNoteLock: true })).rejects.toThrow();
expect(result.warnings).toEqual(['The locked notes in this backup have not been migrated to the current note lock key and will be unreadable']);
});
it('should warn when a backup with locked notes is imported without a handler into a profile with no note lock key', async () => {
await setUpUnlockedSession();
const folder = await Folder.save({ title: 'folder' });
const note = await Note.save({ title: 'note', body: 'secret', parent_id: folder.id });
await lockNote(note.id);
await InteropService.instance().export({ path: exportDir(), format: ExportModuleOutputFormat.Raw });
await setupDatabaseAndSynchronizer(2);
await switchClient(2);
NoteLockSession.destroyInstance();
NoteLockKey.destroyInstance();
Setting.setValue('featureFlag.noteLock', true);
const result = await InteropService.instance().import({ path: exportDir(), format: 'raw' });
expect(result.warnings).toEqual(['The locked notes in this backup cannot be read, because no note lock key has been set up on this profile']);
});
it('should keep locked notes unchanged and warn when the provided key does not fit', async () => {
await setUpUnlockedSession('old password');
const folder = await Folder.save({ title: 'folder' });
const note = await Note.save({ title: 'note', body: 'secret old', parent_id: folder.id });
await lockNote(note.id);
await InteropService.instance().export({ path: exportDir(), format: ExportModuleOutputFormat.Raw });
await rotateProfileKey('new password');
const result = await InteropService.instance().import({
path: exportDir(),
format: 'raw',
onNoteLockKey: async keyFile => ({ id: keyFile.id, plainText: 'not the key' }),
});
const imported = (await Note.all()).find(n => n.id !== note.id && !!n.is_locked);
expect(imported.body).not.toContain('secret');
expect(result.warnings.some(w => w.includes('could not be unlocked'))).toBe(true);
});
});