一些检查失败了
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
384 行
15 KiB
TypeScript
384 行
15 KiB
TypeScript
import Note from '@joplin/lib/models/Note';
|
|
import Setting from '@joplin/lib/models/Setting';
|
|
import { setupDatabaseAndSynchronizer, supportDir, switchClient, withWarningSilenced, noteLockCipherTextStandIn } from '@joplin/lib/testing/test-utils';
|
|
import { act, renderHook, waitFor } from '@testing-library/react';
|
|
import useFormNote, { HookDependencies } from './useFormNote';
|
|
import shim from '@joplin/lib/shim';
|
|
import Resource from '@joplin/lib/models/Resource';
|
|
import ItemChange from '@joplin/lib/models/ItemChange';
|
|
import { join } from 'path';
|
|
import { formNoteToNote } from '.';
|
|
import NoteLockNote from '@joplin/lib/services/noteLock/NoteLockNote';
|
|
import NoteLockSession from '@joplin/lib/services/noteLock/NoteLockSession';
|
|
|
|
const defaultFormNoteProps: HookDependencies = {
|
|
noteId: '',
|
|
isProvisional: false,
|
|
titleInputRef: null,
|
|
editorRef: null,
|
|
onBeforeLoad: () => { },
|
|
onAfterLoad: () => { },
|
|
editorId: 'editor',
|
|
builtInEditorVisible: false,
|
|
noteLockSessionUnlocked: false,
|
|
onDecryptFailedChange: () => {},
|
|
};
|
|
|
|
const deferred = <T>() => {
|
|
let resolve: (value: T)=> void;
|
|
const promise = new Promise<T>(resolvePromise => {
|
|
resolve = resolvePromise;
|
|
});
|
|
return { promise, resolve: resolve! };
|
|
};
|
|
|
|
describe('useFormNote', () => {
|
|
beforeEach(async () => {
|
|
await setupDatabaseAndSynchronizer(1);
|
|
await switchClient(1);
|
|
// Pinned off regardless of the build's default so the fixtures below save raw bodies.
|
|
Setting.setValue('featureFlag.noteLock', false);
|
|
});
|
|
|
|
// The session and decryption internals are covered by the lib tests; here they are mocked to
|
|
// test only the hook's gating: ciphertext must never reach the form note.
|
|
it('should not produce a form note for a locked note while the session is locked, and decrypt it once unlocked', async () => {
|
|
// Saved with the flag off so the raw body stands in for ciphertext; an ungated save with
|
|
// the flag on would encrypt it.
|
|
const testNote = await Note.save({ title: 'Locked note', body: 'ciphertext', is_locked: 1 });
|
|
Setting.setValue('featureFlag.noteLock', true);
|
|
|
|
const isUnlockedMock = jest.spyOn(NoteLockSession.instance(), 'isUnlocked').mockReturnValue(false);
|
|
const decryptedKeyMock = jest.spyOn(NoteLockSession.instance(), 'decryptedKey').mockReturnValue({ id: 'key-id', plainText: 'key' });
|
|
const decryptBodyMock = jest.spyOn(NoteLockNote, 'decryptBody').mockImplementation(async note => ({ ...note, body: 'secret content' }));
|
|
const onBeforeLoad = jest.fn();
|
|
const onDecryptFailedChange = jest.fn();
|
|
|
|
try {
|
|
const lockedRender = renderHook(props => useFormNote(props), {
|
|
initialProps: { ...defaultFormNoteProps, noteId: testNote.id, onBeforeLoad, onDecryptFailedChange },
|
|
});
|
|
// The blocked load produces no state change to wait for; onBeforeLoad is its last
|
|
// await, so once it has run a single flush completes the branch.
|
|
await waitFor(() => expect(onBeforeLoad).toHaveBeenCalled());
|
|
await act(async () => {});
|
|
expect(lockedRender.result.current.formNote).toMatchObject({
|
|
id: '',
|
|
body: '',
|
|
noteLockKey: null,
|
|
});
|
|
expect(lockedRender.result.current.loadBlocked).toBe(true);
|
|
expect(onDecryptFailedChange).toHaveBeenLastCalledWith(false);
|
|
|
|
isUnlockedMock.mockReturnValue(true);
|
|
lockedRender.rerender({ ...defaultFormNoteProps, noteId: testNote.id, onBeforeLoad, onDecryptFailedChange, noteLockSessionUnlocked: true });
|
|
await waitFor(() => {
|
|
expect(lockedRender.result.current.formNote).toMatchObject({
|
|
id: testNote.id,
|
|
is_locked: 1,
|
|
body: 'secret content',
|
|
noteLockKey: { id: 'key-id', plainText: 'key' },
|
|
});
|
|
});
|
|
expect(lockedRender.result.current.loadBlocked).toBe(false);
|
|
lockedRender.unmount();
|
|
|
|
Setting.setValue('featureFlag.noteLock', false);
|
|
isUnlockedMock.mockReturnValue(false);
|
|
const flagOffRender = renderHook(props => useFormNote(props), {
|
|
initialProps: { ...defaultFormNoteProps, noteId: testNote.id },
|
|
});
|
|
await waitFor(() => {
|
|
expect(flagOffRender.result.current.formNote.id).toBe(testNote.id);
|
|
});
|
|
expect(flagOffRender.result.current.formNote).toMatchObject({
|
|
body: 'ciphertext',
|
|
});
|
|
flagOffRender.unmount();
|
|
} finally {
|
|
isUnlockedMock.mockRestore();
|
|
decryptedKeyMock.mockRestore();
|
|
decryptBodyMock.mockRestore();
|
|
Setting.setValue('featureFlag.noteLock', false);
|
|
}
|
|
});
|
|
|
|
it('should report a decryption failure instead of throwing, without producing a form note', async () => {
|
|
const testNote = await Note.save({ title: 'Locked note', body: 'ciphertext', is_locked: 1 });
|
|
Setting.setValue('featureFlag.noteLock', true);
|
|
// The save's own change event would otherwise reach the hook's listener and reload the note.
|
|
await ItemChange.waitForAllSaved();
|
|
|
|
const isUnlockedMock = jest.spyOn(NoteLockSession.instance(), 'isUnlocked').mockReturnValue(true);
|
|
const decryptBodyMock = jest.spyOn(NoteLockNote, 'decryptBody').mockRejectedValue(new Error('OperationError'));
|
|
const onDecryptFailedChange = jest.fn();
|
|
|
|
try {
|
|
const render = renderHook(props => useFormNote(props), {
|
|
initialProps: { ...defaultFormNoteProps, noteId: testNote.id, noteLockSessionUnlocked: true, onDecryptFailedChange },
|
|
});
|
|
await withWarningSilenced(/Could not decrypt locked note/, async () => {
|
|
await waitFor(() => {
|
|
expect(render.result.current.decryptFailed).toBe(true);
|
|
});
|
|
}, { requireWarning: true });
|
|
expect(render.result.current.formNote).toMatchObject({
|
|
id: '',
|
|
body: '',
|
|
});
|
|
expect(render.result.current.loadBlocked).toBe(false);
|
|
expect(onDecryptFailedChange).toHaveBeenLastCalledWith(true);
|
|
render.unmount();
|
|
} finally {
|
|
isUnlockedMock.mockRestore();
|
|
decryptBodyMock.mockRestore();
|
|
Setting.setValue('featureFlag.noteLock', false);
|
|
}
|
|
});
|
|
|
|
it('should update note when decryption completes', async () => {
|
|
const testNote = await Note.save({ title: 'Test Note!' });
|
|
|
|
const makeFormNoteProps = (): HookDependencies => {
|
|
return {
|
|
...defaultFormNoteProps,
|
|
noteId: testNote.id,
|
|
};
|
|
};
|
|
|
|
const formNote = renderHook(props => useFormNote(props), {
|
|
initialProps: makeFormNoteProps(),
|
|
});
|
|
await waitFor(() => {
|
|
// id is falsy until after the first load of the form note.
|
|
expect(formNote.result.current.formNote.id).not.toBeFalsy();
|
|
});
|
|
expect(formNote.result.current.formNote).toMatchObject({
|
|
encryption_applied: 0,
|
|
title: testNote.title,
|
|
});
|
|
|
|
await act(async () => {
|
|
await Note.save({
|
|
id: testNote.id,
|
|
encryption_cipher_text: 'cipher_text',
|
|
encryption_applied: 1,
|
|
});
|
|
});
|
|
|
|
// Changing encryption_applied should cause a re-render
|
|
await waitFor(() => {
|
|
expect(formNote.result.current.formNote).toMatchObject({
|
|
encryption_applied: 1,
|
|
});
|
|
});
|
|
|
|
await act(async () => {
|
|
await Note.save({
|
|
id: testNote.id,
|
|
encryption_applied: 0,
|
|
title: 'Test Note!',
|
|
});
|
|
});
|
|
|
|
// Ending decryption should also cause a re-render
|
|
await waitFor(() => {
|
|
expect(formNote.result.current.formNote).toMatchObject({
|
|
encryption_applied: 0,
|
|
});
|
|
// A larger-than-default timeout is needed to prevent CI failures:
|
|
}, { timeout: 15_000 });
|
|
|
|
formNote.unmount();
|
|
});
|
|
|
|
|
|
it('should show a locked note again after sync re-encrypts it and the decryption worker restores it', async () => {
|
|
Setting.setValue('featureFlag.noteLock', true);
|
|
const testNote = await Note.save({ title: 'Locked', body: noteLockCipherTextStandIn(), is_locked: 1 });
|
|
jest.spyOn(NoteLockSession.instance(), 'isUnlocked').mockReturnValue(true);
|
|
jest.spyOn(NoteLockSession.instance(), 'decryptedKey').mockReturnValue({ id: 'key-id', plainText: 'key' });
|
|
// Like the real decrypt, a row sync has not decrypted yet has no note lock body and fails.
|
|
const decryptBodyMock = jest.spyOn(NoteLockNote, 'decryptBody').mockImplementation(async note => {
|
|
if (note.encryption_applied) throw new Error('Invalid encryption identifier');
|
|
return { ...note, isDecrypted: true, body: 'secret' };
|
|
});
|
|
const onReloadInProgressChange = jest.fn();
|
|
const props = { ...defaultFormNoteProps, noteId: testNote.id, noteLockSessionUnlocked: true, editorNoteReloadTimeRequest: 0, onReloadInProgressChange };
|
|
|
|
try {
|
|
const formNote = renderHook(hookProps => useFormNote(hookProps), { initialProps: props });
|
|
await waitFor(() => expect(formNote.result.current.formNote.body).toBe('secret'));
|
|
|
|
// Sync overwrites the row with the encrypted item and asks the editor to reload.
|
|
await act(async () => {
|
|
await Note.save({ id: testNote.id, encryption_cipher_text: 'cipher_text', encryption_applied: 1, body: '', is_locked: 1 });
|
|
});
|
|
formNote.rerender({ ...props, editorNoteReloadTimeRequest: 1 });
|
|
await waitFor(() => expect(formNote.result.current.formNote).toMatchObject({ encryption_applied: 1 }));
|
|
|
|
// The decryption worker then writes the decrypted item back.
|
|
await act(async () => {
|
|
await Note.save({ id: testNote.id, title: 'Locked', body: noteLockCipherTextStandIn(), is_locked: 1, encryption_cipher_text: '', encryption_applied: 0 }, { autoTimestamp: false, changeSource: ItemChange.SOURCE_DECRYPTION });
|
|
});
|
|
await waitFor(() => expect(formNote.result.current.formNote).toMatchObject({ encryption_applied: 0, body: 'secret' }), { timeout: 15_000 });
|
|
await waitFor(() => expect(onReloadInProgressChange).toHaveBeenLastCalledWith(false));
|
|
formNote.unmount();
|
|
} finally {
|
|
decryptBodyMock.mockRestore();
|
|
Setting.setValue('featureFlag.noteLock', false);
|
|
}
|
|
});
|
|
|
|
// Lacking is_conflict has previously caused UI issues. See https://github.com/laurent22/joplin/pull/10913
|
|
// for details.
|
|
it('should preserve value of is_conflict on save', async () => {
|
|
const testNote = await Note.save({ title: 'Test Note!', is_conflict: 1 });
|
|
|
|
const makeFormNoteProps = (): HookDependencies => {
|
|
return {
|
|
...defaultFormNoteProps,
|
|
noteId: testNote.id,
|
|
};
|
|
};
|
|
|
|
const formNote = renderHook(props => useFormNote(props), {
|
|
initialProps: makeFormNoteProps(),
|
|
});
|
|
await waitFor(() => {
|
|
expect(formNote.result.current.formNote).toMatchObject({
|
|
is_conflict: 1,
|
|
title: testNote.title,
|
|
});
|
|
}, { timeout: 15_000 });
|
|
|
|
// Should preserve is_conflict after save.
|
|
expect(await formNoteToNote(formNote.result.current.formNote)).toMatchObject({
|
|
is_conflict: 1,
|
|
deleted_time: 0,
|
|
title: testNote.title,
|
|
});
|
|
|
|
formNote.unmount();
|
|
});
|
|
|
|
it('should reload the note when it is changed outside of the editor', async () => {
|
|
const note = await Note.save({ title: 'Test Note!', body: '...' });
|
|
const onReloadInProgressChange = jest.fn();
|
|
|
|
const props = {
|
|
...defaultFormNoteProps,
|
|
noteId: note.id,
|
|
onReloadInProgressChange,
|
|
};
|
|
|
|
const formNote = renderHook(props => useFormNote(props), {
|
|
initialProps: props,
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(formNote.result.current.formNote.title).toBe('Test Note!');
|
|
});
|
|
expect(formNote.result.current.formNote.share_id).toBe('');
|
|
|
|
// Simulate the note being modified outside the editor, e.g. sync assigning it to a share
|
|
await act(async () => {
|
|
await Note.save({ id: note.id, title: 'Modified', share_id: 'share-1' });
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(formNote.result.current.formNote.title).toBe('Modified');
|
|
});
|
|
expect(formNote.result.current.formNote.share_id).toBe('share-1');
|
|
expect(onReloadInProgressChange).not.toHaveBeenCalled();
|
|
|
|
formNote.unmount();
|
|
});
|
|
|
|
it('should force a reload when editorNoteReloadTimeRequest changes', async () => {
|
|
const note = await Note.save({ title: 'Original', body: '...' });
|
|
const onReloadInProgressChange = jest.fn();
|
|
const props = { ...defaultFormNoteProps, noteId: note.id, editorNoteReloadTimeRequest: 0, onReloadInProgressChange };
|
|
const formNote = renderHook(hookProps => useFormNote(hookProps), { initialProps: props });
|
|
await waitFor(() => expect(formNote.result.current.formNote.title).toBe('Original'));
|
|
|
|
act(() => {
|
|
formNote.result.current.setFormNote(previous => ({ ...previous, title: 'Unsaved local title', hasChanged: true }));
|
|
});
|
|
await Note.save({ id: note.id, title: 'Remote title' }, { changeId: 'test-editor' });
|
|
formNote.rerender({ ...props, editorNoteReloadTimeRequest: 1 });
|
|
|
|
await waitFor(() => expect(onReloadInProgressChange).toHaveBeenCalledWith(true));
|
|
await waitFor(() => expect(formNote.result.current.formNote.title).toBe('Remote title'));
|
|
await waitFor(() => expect(onReloadInProgressChange).toHaveBeenLastCalledWith(false));
|
|
formNote.unmount();
|
|
});
|
|
|
|
it('should clear reloadInProgress after overlapping reload requests', async () => {
|
|
const note = await Note.save({ title: 'Original', body: '...' });
|
|
const onReloadInProgressChange = jest.fn();
|
|
const props = { ...defaultFormNoteProps, noteId: note.id, editorNoteReloadTimeRequest: 0, onReloadInProgressChange };
|
|
const formNote = renderHook(hookProps => useFormNote(hookProps), { initialProps: props });
|
|
await waitFor(() => expect(formNote.result.current.formNote.title).toBe('Original'));
|
|
|
|
const firstLoad = deferred<typeof note>();
|
|
const lastLoad = deferred<typeof note>();
|
|
const loadMock = jest.spyOn(Note, 'load')
|
|
.mockImplementationOnce(() => firstLoad.promise)
|
|
.mockImplementationOnce(() => lastLoad.promise);
|
|
|
|
formNote.rerender({ ...props, editorNoteReloadTimeRequest: 1 });
|
|
await waitFor(() => expect(loadMock).toHaveBeenCalledTimes(1));
|
|
formNote.rerender({ ...props, editorNoteReloadTimeRequest: 2 });
|
|
|
|
firstLoad.resolve({ ...note, title: 'First reload' });
|
|
await waitFor(() => expect(loadMock).toHaveBeenCalledTimes(2));
|
|
expect(onReloadInProgressChange).toHaveBeenLastCalledWith(true);
|
|
|
|
lastLoad.resolve({ ...note, title: 'Last reload' });
|
|
await waitFor(() => expect(formNote.result.current.formNote.title).toBe('Last reload'));
|
|
await waitFor(() => expect(onReloadInProgressChange).toHaveBeenLastCalledWith(false));
|
|
|
|
loadMock.mockRestore();
|
|
formNote.unmount();
|
|
});
|
|
|
|
test('should refresh resource infos when changed outside the editor', async () => {
|
|
let note = await Note.save({});
|
|
note = await shim.attachFileToNote(note, join(supportDir, 'sample.txt'));
|
|
const resourceIds = Note.linkedItemIds(note.body);
|
|
const resource = await Resource.load(resourceIds[0]);
|
|
|
|
const makeFormNoteProps = (): HookDependencies => {
|
|
return {
|
|
...defaultFormNoteProps,
|
|
noteId: note.id,
|
|
};
|
|
};
|
|
|
|
const formNote = renderHook(props => useFormNote(props), {
|
|
initialProps: makeFormNoteProps(),
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(Object.values(formNote.result.current.resourceInfos).length).toBeGreaterThan(0);
|
|
});
|
|
const initialResourceInfos = formNote.result.current.resourceInfos;
|
|
expect(initialResourceInfos).toMatchObject({
|
|
[resource.id]: { item: { id: resource.id } },
|
|
});
|
|
|
|
await act(async () => {
|
|
await Resource.save({ ...resource, filename: 'test.txt' });
|
|
});
|
|
await waitFor(() => {
|
|
const resourceInfo = formNote.result.current.resourceInfos[resource.id];
|
|
expect(resourceInfo.item).toMatchObject({
|
|
id: resource.id, filename: 'test.txt',
|
|
});
|
|
});
|
|
|
|
formNote.unmount();
|
|
});
|
|
});
|