一些检查失败了
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
301 行
12 KiB
TypeScript
301 行
12 KiB
TypeScript
import Logger from '@joplin/utils/Logger';
|
|
import shim from '../../../shim';
|
|
import { downloadMediaFile, createResourcesFromPaths } from './notes';
|
|
import Setting from '../../../models/Setting';
|
|
import { readFile, readdir, remove, writeFile } from 'fs-extra';
|
|
import Resource from '../../../models/Resource';
|
|
import Api, { RequestMethod } from '../Api';
|
|
import Note from '../../../models/Note';
|
|
import Folder from '../../../models/Folder';
|
|
import Tag from '../../../models/Tag';
|
|
import { setupDatabaseAndSynchronizer, switchClient, noteLockCipherTextStandIn } from '../../../testing/test-utils';
|
|
import Revision from '../../../models/Revision';
|
|
import { ModelType } from '../../../BaseModel';
|
|
import { NoteEntity } from '../../database/types';
|
|
const md5 = require('md5');
|
|
|
|
const imagePath = `${__dirname}/../../../images/SideMenuHeader.png`;
|
|
const jpgBase64Content = '/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAMCAgICAgMCAgIDAwMDBAYEBAQEBAgGBgUGCQgKCgkICQkKDA8MCgsOCwkJDRENDg8QEBEQCgwSExIQEw8QEBD/wAALCAAFAAUBAREA/8QAFAABAAAAAAAAAAAAAAAAAAAACf/EAB8QAAEEAQUBAAAAAAAAAAAAAAQBAgUGAwAREiExM//aAAgBAQAAPwBJarVpGHm7KWbapCSwyZ6FDjkLyYE1W/LHyV2zfOk2TrzX/9k=';
|
|
|
|
describe('routes/notes', () => {
|
|
|
|
beforeEach(async () => {
|
|
jest.resetAllMocks();
|
|
await setupDatabaseAndSynchronizer(1);
|
|
await switchClient(1);
|
|
});
|
|
|
|
test.each([
|
|
'/invalid/url',
|
|
'htp/asdfasf.com',
|
|
'https//joplinapp.org',
|
|
])('should not return a local file for invalid protocols', async (invalidUrl) => {
|
|
expect(await downloadMediaFile(invalidUrl)).toBe('');
|
|
});
|
|
|
|
test.each([
|
|
'https://joplinapp.org/valid/image_url.png',
|
|
'http://joplinapp.org/valid/image_url.png',
|
|
])('should try to download and return a local path to a valid URL', async (url) => {
|
|
const fetchBlobSpy = jest.fn(async (_url, options) => {
|
|
await writeFile(options.path, Buffer.from(jpgBase64Content, 'base64'));
|
|
});
|
|
const spy = jest.spyOn(shim, 'fetchBlob').mockImplementation(fetchBlobSpy);
|
|
|
|
const response = await downloadMediaFile(url);
|
|
|
|
const files = await readdir(Setting.value('tempDir'));
|
|
|
|
expect(files.length).toBe(1);
|
|
expect(fetchBlobSpy).toHaveBeenCalledTimes(1);
|
|
expect(response).toBe(`${Setting.value('tempDir')}/${files[0]}`);
|
|
await remove(response);
|
|
spy.mockRestore();
|
|
});
|
|
|
|
test('should get file from local drive if protocol allows it', async () => {
|
|
const url = `file:///${imagePath}`;
|
|
const originalFileContent = await readFile(imagePath);
|
|
|
|
const response = await downloadMediaFile(url, null, ['file:']);
|
|
|
|
const files = await readdir(Setting.value('tempDir'));
|
|
expect(files.length).toBe(1);
|
|
expect(response).toBe(`${Setting.value('tempDir')}/${files[0]}`);
|
|
|
|
const responseFileContent = await readFile(response);
|
|
expect(md5(responseFileContent)).toBe(md5(originalFileContent));
|
|
await remove(response);
|
|
});
|
|
|
|
test('should be able to handle URLs with data', async () => {
|
|
const url = 'data:image/gif;base64,R0lGODlhEAAQAMQAAORHHOVSKudfOulrSOp3WOyDZu6QdvCchPGolfO0o/XBs/fNwfjZ0frl3/zy7////wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACH5BAkAABAALAAAAAAQABAAAAVVICSOZGlCQAosJ6mu7fiyZeKqNKToQGDsM8hBADgUXoGAiqhSvp5QAnQKGIgUhwFUYLCVDFCrKUE1lBavAViFIDlTImbKC5Gm2hB0SlBCBMQiB0UjIQA7';
|
|
const originalFileContent = Buffer.from(url.split('data:image/gif;base64,')[1], 'base64');
|
|
|
|
const response = await downloadMediaFile(url);
|
|
|
|
const files = await readdir(Setting.value('tempDir'));
|
|
expect(files.length).toBe(1);
|
|
expect(response).toBe(`${Setting.value('tempDir')}/${files[0]}`);
|
|
|
|
const responseFileContent = await readFile(response);
|
|
expect(md5(responseFileContent)).toBe(md5(originalFileContent));
|
|
await remove(response);
|
|
});
|
|
|
|
test('should not process URLs with data that is not image type', async () => {
|
|
const url = 'data:application/octet-stream;base64,dGhpcyBpcyBhIG1lc3NhZ2UK';
|
|
|
|
Logger.globalLogger.enabled = false;
|
|
const response = await downloadMediaFile(url);
|
|
Logger.globalLogger.enabled = true;
|
|
|
|
const files = await readdir(Setting.value('tempDir'));
|
|
expect(files.length).toBe(0);
|
|
expect(response).toBe('');
|
|
});
|
|
|
|
test('should not process URLs from cid: protocol', async () => {
|
|
const url = 'cid:ii_loq3d1100';
|
|
|
|
const response = await downloadMediaFile(url);
|
|
|
|
const files = await readdir(Setting.value('tempDir'));
|
|
expect(files.length).toBe(0);
|
|
expect(response).toBe('');
|
|
});
|
|
|
|
test('should not copy content from invalid protocols', async () => {
|
|
const url = 'file:///home/user/file.db';
|
|
|
|
const allowedProtocols: string[] = [];
|
|
const mediaFilePath = await downloadMediaFile(url, null, allowedProtocols);
|
|
|
|
const files = await readdir(Setting.value('tempDir'));
|
|
expect(files.length).toBe(0);
|
|
expect(mediaFilePath).toBe('');
|
|
});
|
|
|
|
test.each([
|
|
'https://joplinapp.org/valid/image_url',
|
|
'https://joplinapp.org/valid/image_url.invalid_url',
|
|
])('should correct the file extension in filename from files without or invalid ones', async (url) => {
|
|
const spy = jest.spyOn(shim, 'fetchBlob').mockImplementation(async (_url, options) => {
|
|
await writeFile(options.path, Buffer.from(jpgBase64Content, 'base64'));
|
|
return {
|
|
headers: {
|
|
'content-type': 'image/jpg',
|
|
},
|
|
};
|
|
});
|
|
|
|
const response = await downloadMediaFile(url);
|
|
|
|
const files = await readdir(Setting.value('tempDir'));
|
|
expect(files.length).toBe(1);
|
|
expect(response).toBe(`${Setting.value('tempDir')}/${files[0]}`);
|
|
|
|
await remove(response);
|
|
spy.mockRestore();
|
|
});
|
|
|
|
test('should be able to create resource from files in the filesystem', async () => {
|
|
const result = await createResourcesFromPaths([
|
|
{ originalUrl: 'asdf.png', path: `${__dirname}/../../../images/SideMenuHeader.png` },
|
|
]);
|
|
|
|
const resources = await Resource.all();
|
|
|
|
expect(result.length).toBe(1);
|
|
expect(result[0].originalUrl).toBe('asdf.png');
|
|
expect(result[0].path).toBe(`${__dirname}/../../../images/SideMenuHeader.png`);
|
|
expect(result[0].resource.title).toBe('SideMenuHeader.png');
|
|
expect(result[0].resource.file_extension).toBe('png');
|
|
expect(resources.length).toBe(1);
|
|
expect(result[0].resource).toEqual(resources[0]);
|
|
});
|
|
|
|
test('should not create resource from files that does not exist', async () => {
|
|
Logger.globalLogger.enabled = false;
|
|
const result = await createResourcesFromPaths([
|
|
{ originalUrl: 'not-a-real-file', path: '/does/not/exist' },
|
|
]);
|
|
Logger.globalLogger.enabled = true;
|
|
|
|
expect(result[0].resource).toBe(null);
|
|
const resources = await Resource.all();
|
|
expect(resources.length).toBe(0);
|
|
});
|
|
|
|
test('should be able to delete to trash', async () => {
|
|
const api = new Api();
|
|
const note1 = await Note.save({});
|
|
const note2 = await Note.save({});
|
|
const beforeTime = Date.now();
|
|
await api.route(RequestMethod.DELETE, `notes/${note1.id}`);
|
|
await api.route(RequestMethod.DELETE, `notes/${note2.id}`, { permanent: '1' });
|
|
|
|
expect((await Note.load(note1.id)).deleted_time).toBeGreaterThanOrEqual(beforeTime);
|
|
expect(await Note.load(note2.id)).toBeFalsy();
|
|
});
|
|
|
|
test('should not stop execution if a file can not be processed', async () => {
|
|
Logger.globalLogger.enabled = false;
|
|
const result = await createResourcesFromPaths([
|
|
{ originalUrl: 'asdf.png', path: `${__dirname}/bad-path-should-not-exist` },
|
|
{ originalUrl: 'asdf.png', path: `${__dirname}/../../../images/SideMenuHeader.png` },
|
|
]);
|
|
Logger.globalLogger.enabled = true;
|
|
|
|
expect(result.length).toBe(2);
|
|
});
|
|
|
|
test('should not return notes in the trash by default', async () => {
|
|
const api = new Api();
|
|
const note1 = await Note.save({});
|
|
const note2 = await Note.save({});
|
|
await Note.delete(note1.id, { toTrash: true });
|
|
|
|
{
|
|
const notes = await api.route(RequestMethod.GET, 'notes');
|
|
expect(notes.items.length).toBe(1);
|
|
expect(notes.items[0].id).toBe(note2.id);
|
|
}
|
|
|
|
{
|
|
const notes = await api.route(RequestMethod.GET, 'notes', { include_deleted: '1' });
|
|
expect(notes.items.length).toBe(2);
|
|
}
|
|
});
|
|
|
|
test('should not return conflicts by default', async () => {
|
|
const api = new Api();
|
|
const note1 = await Note.save({});
|
|
await Note.save({ is_conflict: 1 });
|
|
|
|
{
|
|
const notes = await api.route(RequestMethod.GET, 'notes');
|
|
expect(notes.items.length).toBe(1);
|
|
expect(notes.items[0].id).toBe(note1.id);
|
|
}
|
|
|
|
{
|
|
const notes = await api.route(RequestMethod.GET, 'notes', { include_conflicts: '1' });
|
|
expect(notes.items.length).toBe(2);
|
|
}
|
|
});
|
|
|
|
test('should be able to delete revisions for a note', async () => {
|
|
const api = new Api();
|
|
const note = await Note.save({});
|
|
const revision = await Revision.save({ item_id: note.id, item_type: ModelType.Note, item_updated_time: note.updated_time });
|
|
await api.route(RequestMethod.DELETE, `notes/${note.id}/revisions`);
|
|
|
|
expect(await Revision.load(revision.id)).toBeFalsy();
|
|
});
|
|
|
|
test('should fail closed on locked note bodies', async () => {
|
|
Setting.setValue('featureFlag.noteLock', true);
|
|
const api = new Api();
|
|
// A JLD-prefixed body passes the save path untouched, standing in for real ciphertext.
|
|
const note = await Note.save({ title: 'locked', body: noteLockCipherTextStandIn(), is_locked: 1 });
|
|
|
|
const metadata = await api.route(RequestMethod.GET, `notes/${note.id}`);
|
|
expect(metadata.id).toBe(note.id);
|
|
// The guard only fires on a requested body, so it relies on the default fields not including one.
|
|
expect('body' in metadata).toBe(false);
|
|
await expect(api.route(RequestMethod.GET, `notes/${note.id}`, { fields: 'id,body' })).rejects.toThrow('locked note');
|
|
await expect(api.route(RequestMethod.GET, `notes/${note.id}`, { fields: 'id,BODY' })).rejects.toThrow('locked note');
|
|
await expect(api.route(RequestMethod.GET, `notes/${note.id}/resources`)).rejects.toThrow('locked note');
|
|
|
|
const renamed = await api.route(RequestMethod.PUT, `notes/${note.id}`, null, JSON.stringify({ title: 'renamed' }));
|
|
expect(renamed.title).toBe('renamed');
|
|
expect('body' in renamed).toBe(false);
|
|
expect((await Note.load(note.id)).title).toBe('renamed');
|
|
await expect(api.route(RequestMethod.PUT, `notes/${note.id}`, null, JSON.stringify({ body: 'overwrite' }))).rejects.toThrow('locked note');
|
|
await expect(api.route(RequestMethod.PUT, `notes/${note.id}`, null, JSON.stringify({ is_locked: 0 }))).rejects.toThrow('lock state');
|
|
expect((await Note.load(note.id)).body).toBe(noteLockCipherTextStandIn());
|
|
});
|
|
|
|
test.each([
|
|
{ label: 'lock a plaintext note', startLocked: 0, value: '1foo' },
|
|
{ label: 'unlock an encrypted note', startLocked: 1, value: '0x1' },
|
|
])('should reject a malformed lock state that would $label', async ({ startLocked, value }) => {
|
|
Setting.setValue('featureFlag.noteLock', true);
|
|
const api = new Api();
|
|
const note = await Note.save({ title: 'note', body: startLocked ? noteLockCipherTextStandIn('body') : 'body', is_locked: startLocked });
|
|
|
|
await expect(api.route(RequestMethod.PUT, `notes/${note.id}`, null, JSON.stringify({ is_locked: value }))).rejects.toThrow('lock state');
|
|
expect((await Note.load(note.id)).is_locked).toBe(startLocked);
|
|
});
|
|
|
|
test('should exclude locked notes from collection responses', async () => {
|
|
Setting.setValue('featureFlag.noteLock', true);
|
|
const api = new Api();
|
|
const folder = await Folder.save({ title: 'folder' });
|
|
const plain = await Note.save({ title: 'plain', body: 'text', parent_id: folder.id });
|
|
const locked = await Note.save({ title: 'locked', body: noteLockCipherTextStandIn(), is_locked: 1, parent_id: folder.id });
|
|
const tag = await Tag.save({ title: 'tag' });
|
|
await Tag.addNote(tag.id, plain.id);
|
|
await Tag.addNote(tag.id, locked.id);
|
|
|
|
for (const path of ['notes', `folders/${folder.id}/notes`, `tags/${tag.id}/notes`]) {
|
|
const response = await api.route(RequestMethod.GET, path, { fields: 'id,body' });
|
|
expect(response.items.map((n: NoteEntity) => n.id)).toEqual([plain.id]);
|
|
}
|
|
|
|
Setting.setValue('featureFlag.noteLock', false);
|
|
expect((await api.route(RequestMethod.GET, 'notes')).items.length).toBe(2);
|
|
});
|
|
|
|
test('should not restrict locked notes when note lock is disabled', async () => {
|
|
Setting.setValue('featureFlag.noteLock', false);
|
|
const api = new Api();
|
|
const note = await Note.save({ title: 'locked', body: 'ciphertext', is_locked: 1 });
|
|
|
|
const loaded = await api.route(RequestMethod.GET, `notes/${note.id}`, { fields: 'id,body' });
|
|
expect(loaded.body).toBe('ciphertext');
|
|
});
|
|
|
|
});
|