文件
mynode/packages/lib/components/shared/note-screen-shared.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

391 行
17 KiB
TypeScript

import Note from '../../models/Note';
import Folder from '../../models/Folder';
import Setting from '../../models/Setting';
import { setupDatabaseAndSynchronizer, switchClient, noteLockCipherTextStandIn } from '../../testing/test-utils';
import shared, { BaseNoteScreenComponent, BaseState } from './note-screen-shared';
import NoteLockNote from '../../services/noteLock/NoteLockNote';
import NoteLockService, { ScopedNoteLockService } from '../../services/noteLock/NoteLockService';
import NoteLockSession from '../../services/noteLock/NoteLockSession';
import { NoteEntity } from '../../services/database/types';
const deferred = <T>() => {
let resolve: (value: T)=> void;
const promise = new Promise<T>(resolvePromise => {
resolve = resolvePromise;
});
return { promise, resolve: resolve! };
};
const newComponent = () => ({
props: {
provisionalNoteIds: [],
noteId: 'note-id',
folders: [],
sharedData: undefined,
noteVisiblePanes: ['editor'],
},
state: { mode: 'edit' },
setState: jest.fn(),
scheduleFocusUpdate: jest.fn(),
}) as unknown as BaseNoteScreenComponent;
const makeComp = (note: NoteEntity, stateOverrides: Partial<BaseState> = {}): BaseNoteScreenComponent => {
return {
props: {
provisionalNoteIds: [],
noteId: note.id,
folders: [],
sharedData: undefined,
noteVisiblePanes: ['viewer'],
},
state: {
note: { ...note },
lastSavedNote: { ...note },
newAndNoTitleChangeNoteId: false,
mode: 'view',
folder: null,
isLoading: false,
fromShare: false,
noteResources: {},
readOnly: false,
noteLastLoadTime: 0,
noteLockKey: null,
...stateOverrides,
},
setState: function(newState: Partial<BaseState>) {
this.state = { ...this.state, ...newState };
},
scheduleSave: () => {},
scheduleFocusUpdate: () => {},
attachFile: () => {},
};
};
const mockUnlockedSession = () => {
jest.spyOn(NoteLockSession.instance(), 'isUnlocked').mockReturnValue(true);
jest.spyOn(NoteLockSession.instance(), 'decryptedKey').mockReturnValue({ id: 'key-id', plainText: 'key' });
jest.spyOn(NoteLockService, 'withDecryptedKey').mockImplementation(
async callback => callback({ encryptString: async (text: string) => `enc(${text})` } as ScopedNoteLockService),
);
jest.spyOn(NoteLockNote, 'decryptBody').mockImplementation(async note => ({
...note,
isDecrypted: true,
body: note.is_locked ? String(note.body).replace(/^enc\(([\s\S]*)\)$/, '$1') : note.body,
}));
};
// Recreating a deleted locked note must gate its full-body save.
const expectGatedLockedSaves = (saveSpy: jest.SpyInstance) => {
for (const [note, options] of saveSpy.mock.calls) {
if ((note as NoteEntity).is_locked && 'body' in (note as NoteEntity)) {
expect((options as { useNoteLock?: boolean })?.useNoteLock).toBe(true);
}
}
};
describe('note-screen-shared', () => {
let folderId = '';
beforeEach(async () => {
await setupDatabaseAndSynchronizer(1);
await switchClient(1);
Setting.setValue('featureFlag.noteLock', true);
folderId = (await Folder.save({ title: 'folder' })).id;
});
afterEach(() => {
Setting.setValue('featureFlag.noteLock', false);
jest.restoreAllMocks();
});
it('should not expose a locked note body while the session is locked, and decrypt it while unlocked', async () => {
// A JLD-prefixed body passes the save path untouched, standing in for real ciphertext.
const testNote = await Note.save({ title: 'Locked', body: noteLockCipherTextStandIn(), is_locked: 1, parent_id: folderId });
jest.spyOn(NoteLockSession.instance(), 'isUnlocked').mockReturnValue(false);
const lockedComp = makeComp(testNote);
await shared.reloadNote(lockedComp);
// The encrypted body stays in both state notes, so a diff-based save cannot write it.
expect(lockedComp.state.note.body).toBe(noteLockCipherTextStandIn());
expect(lockedComp.state.lastSavedNote.body).toBe(noteLockCipherTextStandIn());
expect(lockedComp.state.readOnly).toBe(true);
expect(lockedComp.state.mode).toBe('view');
expect(lockedComp.state.noteLockKey).toBeNull();
jest.spyOn(NoteLockSession.instance(), 'isUnlocked').mockReturnValue(true);
jest.spyOn(NoteLockSession.instance(), 'decryptedKey').mockReturnValue({ id: 'key-id', plainText: 'key' });
jest.spyOn(NoteLockNote, 'decryptBody').mockImplementation(async note => ({ ...note, body: 'secret content' }));
const unlockedComp = makeComp(testNote);
await shared.reloadNote(unlockedComp);
expect(unlockedComp.state.note.body).toBe('secret content');
expect(unlockedComp.state.readOnly).toBe(false);
expect(unlockedComp.state.noteLockKey).toEqual({ id: 'key-id', plainText: 'key' });
});
it('should report a locked note as undecryptable only when the session is still unlocked', async () => {
const testNote = await Note.save({ title: 'Locked', body: noteLockCipherTextStandIn(), is_locked: 1, parent_id: folderId });
jest.spyOn(NoteLockSession.instance(), 'isUnlocked').mockReturnValue(true);
jest.spyOn(NoteLockSession.instance(), 'decryptedKey').mockReturnValue({ id: 'key-id', plainText: 'key' });
jest.spyOn(NoteLockNote, 'decryptBody').mockRejectedValue(new Error('OperationError'));
const undecryptableComp = makeComp(testNote);
await shared.reloadNote(undecryptableComp);
expect(undecryptableComp.state.noteLockUndecryptable).toBe(true);
expect(undecryptableComp.state.note.body).toBe(noteLockCipherTextStandIn());
expect(undecryptableComp.state.readOnly).toBe(true);
expect(undecryptableComp.state.noteLockKey).toBeNull();
// Unlocked when the load starts, locked by the time the key is captured: still recoverable.
jest.spyOn(NoteLockSession.instance(), 'isUnlocked').mockReturnValueOnce(true).mockReturnValue(false);
jest.spyOn(NoteLockNote, 'decryptBody').mockImplementation(async note => ({ ...note, body: 'secret content' }));
jest.spyOn(NoteLockSession.instance(), 'decryptedKey').mockImplementation(() => {
throw new Error('Note lock session is locked');
});
const racedComp = makeComp(testNote);
await shared.reloadNote(racedComp);
expect(racedComp.state.noteLockUndecryptable).toBe(false);
expect(racedComp.state.note.body).toBe(noteLockCipherTextStandIn());
});
it('should persist the encrypted body together with a lock state change', async () => {
const testNote = await Note.save({ title: 'Plain', body: 'plain text', parent_id: folderId });
mockUnlockedSession();
// The enable flow flips is_locked in the state note; the diff then only contains
// is_locked, and the save must still write the encrypted body with it.
const flippedNote = { ...testNote, is_locked: 1, isDecrypted: true };
const comp = makeComp(testNote, { note: flippedNote });
await shared.saveNoteButton_press(comp, comp.state, null, null);
const savedNote = await Note.load(testNote.id);
expect(savedNote.is_locked).toBe(1);
expect(savedNote.body).toBe('enc(plain text)');
// The state must keep the plaintext so later diffs and the editor keep working.
expect(comp.state.note.body).toBe('plain text');
expect(comp.state.lastSavedNote.body).toBe('plain text');
});
it('should keep the decrypted state when a property save recreates a deleted note', async () => {
mockUnlockedSession();
const testNote = await Note.save({ title: 'Locked', body: '- [ ] task', is_locked: 1, parent_id: folderId });
const loadedNote = { ...testNote, body: '- [ ] task', isDecrypted: true };
const comp = makeComp(loadedNote, { noteLockKey: { id: 'key-id', plainText: 'key' } });
// e.g. deleted from another client while the note was open.
await Note.batchDelete([testNote.id]);
await shared.saveOneProperty(comp, 'todo_completed', 1);
const newId = comp.state.note.id;
expect(newId).not.toBe(testNote.id);
const savedNote = await Note.load(newId);
expect(savedNote.is_locked).toBe(1);
expect(savedNote.body).toBe('enc(- [ ] task)');
expect(savedNote.todo_completed).toBe(1);
expect(comp.state.note.isDecrypted).toBe(true);
});
it('should save a body property change and keep it in the state', async () => {
// Reloaded through the gate so the state note carries the marker, like after reloadNote.
const testNote = await Note.load((await Note.save({ title: 'Task note', body: '- [ ] task', parent_id: folderId })).id, { useNoteLock: true });
const comp = makeComp(testNote);
await shared.saveOneProperty(comp, 'body', '- [x] task');
expect((await Note.load(testNote.id)).body).toBe('- [x] task');
expect(comp.state.note.body).toBe('- [x] task');
expect(comp.state.lastSavedNote.body).toBe('- [x] task');
// The body save is gated, so a state note without the marker is refused.
await expect(shared.saveOneProperty(makeComp(testNote, { note: { ...testNote, isDecrypted: false } }), 'body', '- [ ] task')).rejects.toThrow('Gated note lock save is missing decrypted state');
});
it('should not revert a lock state change that happens while a save is in flight', async () => {
// Reloaded through the gate so the state note carries all columns and the marker, like after reloadNote.
const testNote = await Note.load((await Note.save({ title: 'Plain', body: 'plain text', parent_id: folderId })).id, { useNoteLock: true });
mockUnlockedSession();
const comp = makeComp(testNote);
let saveStarted: ()=> void;
const saveStartedPromise = new Promise<void>(resolve => { saveStarted = resolve; });
let finishSave: ()=> void;
const finishSavePromise = new Promise<void>(resolve => { finishSave = resolve; });
const originalSave = Note.save.bind(Note);
jest.spyOn(Note, 'save').mockImplementationOnce(async (note, options) => {
saveStarted();
await finishSavePromise;
return originalSave(note, options);
});
const savePromise = shared.saveNoteButton_press(comp, { ...comp.state, note: { ...comp.state.note, body: 'edited text' } }, null, null);
await saveStartedPromise;
// Encryption enabled from the menu while the body save is running.
const flippedNote = { ...comp.state.note, is_locked: 1, isDecrypted: true };
comp.state.note = flippedNote;
finishSave();
await savePromise;
expect(comp.state.note.is_locked).toBe(1);
expect(comp.state.note.isDecrypted).toBe(true);
// The scheduled lock save runs against that state.
await shared.saveNoteButton_press(comp, comp.state, null, null);
const savedNote = await Note.load(testNote.id);
expect(savedNote.is_locked).toBe(1);
expect(savedNote.body).toMatch(/^enc\(/);
});
it('should keep the decrypted state when the note is recreated after being deleted while editing', async () => {
const testNote = await Note.save({ title: 'Plain', body: 'plain text', parent_id: folderId });
mockUnlockedSession();
// The provisional-note cleanup can delete the note while the enable flow's save is
// still queued, e.g. new note -> enable encryption -> unlock via the Config screen.
const flippedNote = { ...testNote, is_locked: 1, isDecrypted: true };
const comp = makeComp(testNote, { note: flippedNote });
await Note.batchDelete([testNote.id]);
const saveSpy = jest.spyOn(Note, 'save');
await shared.saveNoteButton_press(comp, comp.state, null, null);
const newId = comp.state.note.id;
expect(newId).not.toBe(testNote.id);
const savedNote = await Note.load(newId);
expect(savedNote.is_locked).toBe(1);
expect(savedNote.body).toBe('enc(plain text)');
expect(comp.state.note.body).toBe('plain text');
expect(comp.state.note.isDecrypted).toBe(true);
expectGatedLockedSaves(saveSpy);
});
it('should persist the lock state for a pending save that does not touch the body', async () => {
const testNote = await Note.load((await Note.save({ title: 'Plain', body: 'plain text', parent_id: folderId })).id);
mockUnlockedSession();
const comp = makeComp(testNote);
// A title-only save scheduled before the flip: its field diff has no is_locked or body,
// so only the lastSavedNote comparison can pull the lock transition into the save.
const snapshotState = { ...comp.state, note: { ...comp.state.note, title: 'edited title' } };
const flippedNote = { ...comp.state.note, is_locked: 1, isDecrypted: true };
comp.state.note = flippedNote;
await shared.saveNoteButton_press(comp, snapshotState, null, null);
const savedNote = await Note.load(testNote.id);
expect(savedNote.is_locked).toBe(1);
expect(savedNote.title).toBe('edited title');
expect(savedNote.body).toBe('enc(plain text)');
});
it('should not report a note as modified once its save has completed', async () => {
const testNote = await Note.load((await Note.save({ title: 'Plain', body: 'plain text', parent_id: folderId })).id, { useNoteLock: true });
// The save stamps the decrypted-state marker onto only one of the state notes; counting
// that as a modification hides a locked note's lock panel, which exposes the ciphertext.
const comp = makeComp(testNote, { note: { ...testNote, body: 'edited text' } });
await shared.saveNoteButton_press(comp, comp.state, null, null);
expect(shared.isModified(comp)).toBe(false);
});
it('should not report an undecryptable note as modified after a property save', async () => {
const saved = await Note.save({ title: 'Old key', body: noteLockCipherTextStandIn('secret'), is_locked: 1, parent_id: folderId });
// Backdated so the property save below is guaranteed to stamp newer timestamps.
await Note.save({ id: saved.id, updated_time: 1000, user_updated_time: 1000 }, { autoTimestamp: false });
const comp = makeComp(await Note.load(saved.id), { noteLockUndecryptable: true });
await shared.saveOneProperty(comp, 'todo_completed', Date.now());
expect(comp.state.note.updated_time).toBe(comp.state.lastSavedNote.updated_time);
expect(comp.state.note.user_updated_time).toBe(comp.state.lastSavedNote.user_updated_time);
expect(shared.isModified(comp)).toBe(false);
});
it('should save with the latest lock state when it changed after scheduling', async () => {
const testNote = await Note.save({ title: 'Plain', body: 'plain text', parent_id: folderId });
mockUnlockedSession();
const comp = makeComp(testNote);
// Snapshot taken before the flip, e.g. a pending body save scheduled before encryption
// was enabled from the menu.
const snapshotState = { ...comp.state, note: { ...comp.state.note, body: 'edited text' } };
const flippedNote = { ...comp.state.note, is_locked: 1, isDecrypted: true };
comp.state.note = flippedNote;
await shared.saveNoteButton_press(comp, snapshotState, null, null);
const savedNote = await Note.load(testNote.id);
expect(savedNote.is_locked).toBe(1);
expect(savedNote.body).toBe('enc(edited text)');
});
it('should mark an unlocked note as gated on reload so its gated saves pass the marker check', async () => {
const testNote = await Note.save({ title: 'Plain', body: 'plain text', parent_id: folderId });
const comp = makeComp(testNote);
await shared.reloadNote(comp);
await shared.saveNoteButton_press(comp, { ...comp.state, note: { ...comp.state.note, body: 'edited text' } }, null, null);
expect((await Note.load(testNote.id)).body).toBe('edited text');
});
it('should keep the plaintext body in the state when a locked note body is saved without the gate', async () => {
mockUnlockedSession();
const testNote = await Note.save({ title: 'Locked', body: '- [ ] task', is_locked: 1, parent_id: folderId });
const comp = makeComp(testNote, { note: { ...testNote, body: '- [ ] task', isDecrypted: true } });
await shared.saveOneProperty(comp, 'body', '- [x] task');
expect((await Note.load(testNote.id)).body).toBe('enc(- [x] task)');
expect(comp.state.note.body).toBe('- [x] task');
expect(comp.state.lastSavedNote.body).toBe('- [x] task');
});
it('should reload an encrypted note after decrypting it', async () => {
jest.spyOn(shared, 'attachedResources').mockResolvedValue({});
const encryptedNote = { id: 'note-id', encryption_cipher_text: 'cipher text', deleted_time: 0 };
const decryptedNote = { ...encryptedNote, encryption_cipher_text: '', title: 'Title', body: 'Body' };
const decryptStarted = deferred<void>();
const decryption = deferred<typeof decryptedNote>();
jest.spyOn(Note, 'load').mockResolvedValue(encryptedNote as never);
jest.spyOn(Note, 'decrypt').mockImplementation(() => {
decryptStarted.resolve();
return decryption.promise as never;
});
const component = newComponent();
const reloadPromise = shared.reloadNote(component);
await decryptStarted.promise;
expect(component.setState).not.toHaveBeenCalled();
decryption.resolve(decryptedNote);
await reloadPromise;
expect(component.setState).toHaveBeenCalledWith(expect.objectContaining({ note: { ...decryptedNote, isDecrypted: true } }));
});
it.each([
['the master key is not loaded', Object.assign(new Error('Master key is not loaded'), { code: 'masterKeyNotLoaded' })],
['decryption otherwise fails', new Error('Invalid ciphertext')],
])('should use the empty-note branch when %s', async (_description, error) => {
jest.spyOn(shared, 'attachedResources').mockResolvedValue({});
const encryptedNote = { id: 'note-id', encryption_cipher_text: 'cipher text' };
jest.spyOn(Note, 'load').mockResolvedValue(encryptedNote as never);
jest.spyOn(Note, 'decrypt').mockRejectedValue(error);
const component = newComponent();
const result = await shared.reloadNote(component);
expect(result).toBeNull();
expect(component.setState).toHaveBeenCalledWith(expect.objectContaining({ note: {}, isLoading: true }));
});
});