一些检查失败了
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
391 行
17 KiB
TypeScript
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 }));
|
|
});
|
|
});
|